mirror of
https://github.com/Lissy93/awesome-privacy.git
synced 2026-03-11 08:55:33 +00:00
Merge branch 'main' of github.com:lissy93/awesome-privacy into patch-1
This commit is contained in:
commit
72ebf9f0f7
117 changed files with 12929 additions and 8471 deletions
24
.github/CONTRIBUTING.md
vendored
24
.github/CONTRIBUTING.md
vendored
|
|
@ -19,7 +19,7 @@
|
|||
You can add, edit or remove entries by opening a pull request.
|
||||
|
||||
All data is stored in [`awesome-privacy.yml`](https://github.com/Lissy93/awesome-privacy/blob/main/awesome-privacy.yml).
|
||||
If you're adding, editing or removing a listing - **this is the only file you need to edit**.
|
||||
If you're adding, editing or removing a listing - **this is the only file you need to edit**. Don't edit the README directly, as this is auto-generated from the YAML file.
|
||||
|
||||
### Process
|
||||
|
||||
|
|
@ -62,6 +62,10 @@ For software to be included in this list, it must meet the following requirement
|
|||
- A stable (non-alpha/beta) release is required at a minimum
|
||||
- Must be accessible to the general public, and not just a select group of people
|
||||
- If technical knowledge is required to run it, the software must be well documented
|
||||
- **Mature**
|
||||
- Software needs to have a proven track record of commitment to maintenance
|
||||
- Repositories must not be newly created, and the first stable release older than 4 months
|
||||
- Projects primarily written with AI or vibe coded are not suitable for listing here
|
||||
|
||||
_There may be some exceptions, but these would need to be fully justified, reviewed
|
||||
by the community, and the drawbacks / anti-features must be clearly listed along-side the software.
|
||||
|
|
@ -74,25 +78,23 @@ Usually these entries go within the "Notable Mentions" section instead._
|
|||
|
||||
Your pull request must follow these requirements. Failure to do so, might result in it being closed.
|
||||
|
||||
- Do not edit the README directly when adding / editing a listing
|
||||
- Do not edit the README directly when adding / editing a listing (it's auto-generated!)
|
||||
- Ensure your PR is not a duplicate, search for existing / previous submissions first
|
||||
- You must respond to any comments or requests for changes in a timely manner, 48-hours maximum
|
||||
- You must respond to any comments or requests for changes in a timely manner, 14 days maximum
|
||||
- Write short but descriptive git commit messages, under 50 characters. This must be in the format of `Adds [software-name] to [section-name]`. Your PR will be rejected if you name it `Updates README.md`
|
||||
- Only include a single addition / amendment / removal, per pull request
|
||||
- If your pull request contains multiple commits, you must squash them first
|
||||
- You must complete each of the sections in the pull request template. Do not delete it!
|
||||
- You must complete each of the sections in the [pull request template](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md). Do not delete it!
|
||||
- Where applicable, include links to supporting material for your addition: git repo, docs, recent security audits, etc. This will make researching it much easier for reviewers
|
||||
- While adding new software to the list, don't make your entry read like an advert. Be objective, and include drawbacks as well as strengths
|
||||
- Your entry should be added at the bottom of the appropriate category, unless otherwise requested
|
||||
- If there are other pull requests open, please help review them before submitting yours
|
||||
- A pull request must receive multiple approval reviews before it can be merged
|
||||
- You must be transparent about your affiliation with a product or service that you are adding. It's totally okay to submit your own projects as additions (providing they meet the requirements), but if you don't declare your association with that project then there becomes a clear conflict of interest
|
||||
- You must adhere to the Contributor Covenant Code of Conduct
|
||||
- You must adhere to the [Contributor Covenant Code of Conduct](https://github.com/Lissy93/awesome-privacy?tab=coc-ov-file#contributor-covenant-code-of-conduct)
|
||||
- Don't open a Draft / WIP pull request while you work on the guidelines. A pull request should be 100% ready and should adhere to all the above guidelines when you open it
|
||||
- Your changes must be correctly spelled, and with good grammar
|
||||
- Your changes must be correctly formatted, in valid markdown
|
||||
- The addition title must be a link the project, and in bold
|
||||
- Your changes must be correctly formatted, in valid yaml and markdown
|
||||
- The addition description must be no less than 50, and no more than 250 characters, keep it clear and to the point
|
||||
- If there are other pull requests open, please help review them before submitting yours
|
||||
- A pull request must receive multiple approval reviews before it can be merged
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -103,8 +105,6 @@ This file may look a bit daunting to start with, but don't worry - it's pretty s
|
|||
|
||||
### Top-Level Structure
|
||||
|
||||
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: Class Diagram
|
||||
|
|
|
|||
3086
.github/README.md
vendored
3086
.github/README.md
vendored
File diff suppressed because it is too large
Load diff
42
.github/workflows/check-domain.yml
vendored
42
.github/workflows/check-domain.yml
vendored
|
|
@ -1,42 +0,0 @@
|
|||
# Checks domain and SSL status, then raises an issue if either is expiring soon
|
||||
name: 🌎 Check Domain Expiry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 5 * * 6' # Every Saturday morning.
|
||||
jobs:
|
||||
check-domain:
|
||||
runs-on: ubuntu-latest
|
||||
name: Check domain
|
||||
strategy:
|
||||
matrix:
|
||||
domain:
|
||||
- https://awesome-privacy.xyz
|
||||
steps:
|
||||
- name: Check domain SSL and registry expire date
|
||||
id: check-domain
|
||||
uses: codex-team/action-check-domain@v1
|
||||
with:
|
||||
url: ${{ matrix.domain }}
|
||||
- name: Raise issue if domain expiring soon
|
||||
if: ${{ steps.check-domain.outputs.paid-till-days-left && steps.check-domain.outputs.paid-till-days-left < 30 }}
|
||||
uses: rishabhgupta/git-action-issue@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
assignees: Lissy93
|
||||
title: '[WEBSITE] Domain Expiring Soon'
|
||||
body: >
|
||||
**Priority Notice**
|
||||
Domain, ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.paid-till-days-left }} days.
|
||||
@Lissy93 - Please take action immediately to prevent any downtime
|
||||
- name: Raise issue if SSL Cert expiring soon
|
||||
if: ${{ steps.check-domain.outputs.ssl-expire-days-left && steps.check-domain.outputs.ssl-expire-days-left < 14 }}
|
||||
uses: rishabhgupta/git-action-issue@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
assignees: Lissy93
|
||||
title: '[WEBSITE] SSL Cert Expiring Soon'
|
||||
body: >
|
||||
**Priority Notice**
|
||||
The SSL Certificate for ${{ matrix.domain }} will expire in ${{ steps.check-domain.outputs.ssl-expire-days-left }} days, on ${{ steps.check-domain.outputs.ssl-expire-date }}.
|
||||
@Lissy93 - Please take action immediately to prevent any downtime
|
||||
44
.github/workflows/compile-pdf.yml
vendored
44
.github/workflows/compile-pdf.yml
vendored
|
|
@ -1,44 +0,0 @@
|
|||
# Generates and saved a PDF document from the main markdown file
|
||||
# Easier to read on certain devices, or for users with accesibility needs
|
||||
|
||||
name: 📁 Compile PDF Document
|
||||
on:
|
||||
workflow_dispatch: # Manual dispatch
|
||||
schedule:
|
||||
- cron: '0 5 * * 6' # Every Saturday morning.
|
||||
jobs:
|
||||
# Job #1 - Generate an embedded SVG asset, showing all contributors
|
||||
compile-pdf:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2
|
||||
- name: Make PDF 📄
|
||||
uses: baileyjm02/markdown-to-pdf@v1.1.0
|
||||
with:
|
||||
input_dir: .
|
||||
output_dir: .github/assets/
|
||||
build_pdf: true
|
||||
build_html: false
|
||||
table_of_contents: false
|
||||
- name: Upload Artifact 📤
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: awesome-privacy-pdf
|
||||
path: .github/assets/README.pdf
|
||||
- name: Commit file ✅
|
||||
run: |
|
||||
git config --local user.email "alicia-gh-bot@mail.as93.net"
|
||||
git config --local user.name "liss-bot"
|
||||
git add .github/assets/*.pdf
|
||||
if ! git diff-index --quiet HEAD; then
|
||||
git commit -m "Generate PDF file"
|
||||
else
|
||||
echo "Nothing to do"
|
||||
fi
|
||||
- name: Push changes ➡️
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ github.ref }}
|
||||
|
||||
34
.github/workflows/credits.yml
vendored
34
.github/workflows/credits.yml
vendored
|
|
@ -1,34 +0,0 @@
|
|||
# Inserts list of contributors and community members into ./docs/credits.md
|
||||
name: 📊 Generate Contributor Credits
|
||||
on:
|
||||
workflow_dispatch: # Manual dispatch
|
||||
schedule:
|
||||
- cron: '0 5 * * 6' # Every Saturday morning.
|
||||
jobs:
|
||||
# Job #1 - Inserts sponsors into README
|
||||
insert-sponsors:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2
|
||||
- name: Generate Sponsors in Credits 💖
|
||||
uses: JamesIves/github-sponsors-readme-action@1.0.5
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
file: '.github/README.md'
|
||||
# Job #2 - Inserts contributors into README
|
||||
insert-credits:
|
||||
runs-on: ubuntu-latest
|
||||
name: Inserts contributors into credits.md
|
||||
steps:
|
||||
- name: Contribute List - Credits Page
|
||||
uses: akhilmhdh/contributors-readme-action@v2.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
image_size: 80
|
||||
readme_path: .github/README.md
|
||||
columns_per_row: 6
|
||||
commit_message: 'Updates contributors list'
|
||||
committer_username: liss-bot
|
||||
committer_email: liss-bot@d0h.co
|
||||
16
.github/workflows/mirror.yml
vendored
Normal file
16
.github/workflows/mirror.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Syncs repo to the Codeberg mirror
|
||||
name: 🪞 Mirror
|
||||
on:
|
||||
schedule: [{ cron: '0 5 * * 0' }]
|
||||
push: { tags: ['v*'] }
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: lissy93/repo-mirror-action@main
|
||||
with:
|
||||
ssh_key: ${{ secrets.CODEBERG_SSH }}
|
||||
host: git@codeberg.org
|
||||
user: alicia
|
||||
repo: awesome-privacy
|
||||
166
.github/workflows/pr-check.yml
vendored
Normal file
166
.github/workflows/pr-check.yml
vendored
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
paths:
|
||||
- 'awesome-privacy.yml'
|
||||
- '.github/README.md'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
pr-compliance:
|
||||
name: PR Compliance
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Check README edits
|
||||
id: readme
|
||||
continue-on-error: true
|
||||
run: python lib/checks/check-readme-edits.py --base-ref ${{ github.event.pull_request.base.sha }}
|
||||
- name: Check PR metadata
|
||||
id: meta
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_DRAFT: ${{ github.event.pull_request.draft }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.sha }}
|
||||
README_FAILED: ${{ steps.readme.outcome == 'failure' && 'true' || 'false' }}
|
||||
run: python lib/checks/check-pr-meta.py
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: findings-compliance
|
||||
path: /tmp/findings-compliance.json
|
||||
if-no-files-found: ignore
|
||||
- name: Fail if critical
|
||||
if: steps.readme.outcome == 'failure' || steps.meta.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
data-validation:
|
||||
name: Data Validation
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
yaml_changed: ${{ steps.changes.outputs.yaml_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: python lib/checks/detect-changes.py --base-ref ${{ github.event.pull_request.base.sha }}
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
run: pip install -q -r lib/requirements.txt
|
||||
- name: Schema validation
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
id: schema
|
||||
continue-on-error: true
|
||||
run: make validate
|
||||
- name: YAML diff
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
id: diff
|
||||
continue-on-error: true
|
||||
run: python lib/checks/check-yaml-diff.py --base-ref ${{ github.event.pull_request.base.sha }}
|
||||
- name: Check additions
|
||||
if: steps.changes.outputs.yaml_changed == 'true'
|
||||
env:
|
||||
SCHEMA_OUTCOME: ${{ steps.schema.outcome }}
|
||||
run: python lib/checks/check-additions.py
|
||||
- name: Upload diff data
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-diff
|
||||
path: |
|
||||
/tmp/pr-diff.json
|
||||
/tmp/pr-diff-summary.md
|
||||
if-no-files-found: ignore
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: findings-data
|
||||
path: /tmp/findings-data.json
|
||||
if-no-files-found: ignore
|
||||
- name: Fail if critical
|
||||
if: steps.changes.outputs.yaml_changed == 'true' && (steps.schema.outcome == 'failure' || steps.diff.outcome == 'failure')
|
||||
run: exit 1
|
||||
|
||||
submission-eligibility:
|
||||
name: Submission Eligibility
|
||||
needs: data-validation
|
||||
if: "!cancelled() && needs.data-validation.outputs.yaml_changed == 'true'"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -q -r lib/requirements.txt
|
||||
- name: Download diff data
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-diff
|
||||
path: /tmp
|
||||
continue-on-error: true
|
||||
- name: Check project health
|
||||
env:
|
||||
PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python lib/checks/check-project.py
|
||||
- name: Upload findings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: findings-project
|
||||
path: /tmp/findings-project.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
summary:
|
||||
name: Summary
|
||||
if: always()
|
||||
needs: [pr-compliance, data-validation, submission-eligibility]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Download all findings
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: findings-*
|
||||
path: /tmp/artifacts
|
||||
merge-multiple: true
|
||||
continue-on-error: true
|
||||
- name: Download diff data
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-diff
|
||||
path: /tmp/artifacts
|
||||
continue-on-error: true
|
||||
- name: Format comment
|
||||
env:
|
||||
PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: python lib/checks/format-comment.py
|
||||
- name: Upload PR metadata
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-meta
|
||||
path: /tmp/pr-meta/
|
||||
221
.github/workflows/pr-comment.yml
vendored
Normal file
221
.github/workflows/pr-comment.yml
vendored
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
name: PR Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["PR Check"]
|
||||
types: [completed]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
checks: read
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Post PR comment
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download PR metadata
|
||||
id: download
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-meta
|
||||
path: pr-meta
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve PR context
|
||||
id: context
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// Determine the PR number
|
||||
let prNumber;
|
||||
const numberFile = 'pr-meta/number.txt';
|
||||
if (fs.existsSync(numberFile)) {
|
||||
prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim());
|
||||
}
|
||||
if (!prNumber) {
|
||||
const prs = context.payload.workflow_run.pull_requests;
|
||||
if (prs && prs.length > 0) {
|
||||
prNumber = prs[0].number;
|
||||
} else {
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const { data: prList } = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
});
|
||||
const match = prList.find(pr => pr.head.sha === headSha);
|
||||
if (!match) {
|
||||
console.log(`No open PR found for SHA ${headSha} — skipping.`);
|
||||
return;
|
||||
}
|
||||
prNumber = match.number;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch existing bot comment (if any) and write to file for Python
|
||||
const marker = '<!-- pr-check-bot -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
fs.mkdirSync('pr-meta', { recursive: true });
|
||||
fs.writeFileSync('pr-meta/existing-comment.md', existing.body);
|
||||
fs.writeFileSync('pr-meta/existing-comment-id.txt', String(existing.id));
|
||||
}
|
||||
|
||||
core.setOutput('pr_number', prNumber);
|
||||
|
||||
- name: Prepare comment
|
||||
if: steps.context.outputs.pr_number
|
||||
env:
|
||||
CHECK_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: python lib/checks/prepare-comment.py
|
||||
|
||||
- name: Post or update comment
|
||||
if: steps.context.outputs.pr_number
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const actionFile = 'pr-meta/action.txt';
|
||||
if (!fs.existsSync(actionFile)) return;
|
||||
|
||||
const action = fs.readFileSync(actionFile, 'utf8').trim();
|
||||
if (action === 'skip') {
|
||||
console.log('Nothing to do — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyFile = 'pr-meta/final-comment.md';
|
||||
if (!fs.existsSync(bodyFile)) {
|
||||
console.log('No comment body found — skipping.');
|
||||
return;
|
||||
}
|
||||
const body = fs.readFileSync(bodyFile, 'utf8').trim();
|
||||
const prNumber = parseInt('${{ steps.context.outputs.pr_number }}');
|
||||
|
||||
if (action === 'create') {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
console.log('Created bot comment.');
|
||||
} else if (action === 'update') {
|
||||
const commentId = parseInt(fs.readFileSync('pr-meta/existing-comment-id.txt', 'utf8').trim());
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
console.log('Updated bot comment.');
|
||||
}
|
||||
|
||||
review-notify:
|
||||
name: Notify maintainer
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request_review'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch review and CI context
|
||||
id: context
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const pr = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Fetch reviews — extract user login and state
|
||||
const { data: reviews } = await github.rest.pulls.listReviews({
|
||||
owner, repo, pull_number: pr.number, per_page: 100,
|
||||
});
|
||||
const reviewData = reviews.map(r => ({
|
||||
user: r.user.login,
|
||||
state: r.state,
|
||||
}));
|
||||
|
||||
// Fetch check runs for the PR head SHA
|
||||
const { data: { check_runs } } = await github.rest.checks.listForRef({
|
||||
owner, repo, ref: pr.head.sha, per_page: 100,
|
||||
});
|
||||
const checkData = check_runs.map(cr => ({
|
||||
status: cr.status,
|
||||
conclusion: cr.conclusion,
|
||||
}));
|
||||
|
||||
// Check if we already posted the notification
|
||||
const marker = '<!-- pr-review-ready -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
const alreadyNotified = comments.some(c => c.body.includes(marker));
|
||||
|
||||
// Write data for Python
|
||||
fs.mkdirSync('pr-meta', { recursive: true });
|
||||
fs.writeFileSync('pr-meta/reviews.json', JSON.stringify(reviewData));
|
||||
fs.writeFileSync('pr-meta/check-runs.json', JSON.stringify(checkData));
|
||||
fs.writeFileSync('pr-meta/already-notified.txt', String(alreadyNotified));
|
||||
|
||||
core.setOutput('pr_number', pr.number);
|
||||
|
||||
- name: Check review readiness
|
||||
run: python lib/checks/check-review-ready.py
|
||||
|
||||
- name: Post notification
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const actionFile = 'pr-meta/action.txt';
|
||||
if (!fs.existsSync(actionFile)) return;
|
||||
|
||||
const action = fs.readFileSync(actionFile, 'utf8').trim();
|
||||
if (action !== 'notify') {
|
||||
console.log('Not ready for review — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
'<!-- pr-review-ready -->',
|
||||
'This PR is now ready to be merged, pending maintainer review. All checks are passing and it has been peer-reviewed.',
|
||||
'',
|
||||
'@Lissy93 - Please evaluate, and either merge or leave feedback.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: parseInt('${{ steps.context.outputs.pr_number }}'),
|
||||
body,
|
||||
});
|
||||
console.log('Posted maintainer notification.');
|
||||
25
.github/workflows/pr-labeler.yml
vendored
25
.github/workflows/pr-labeler.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
# Applies labels based on the pull request category
|
||||
name: 🏷️ PR Labeler
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
jobs:
|
||||
label-pr:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Apply Labels
|
||||
if: "! contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc')"
|
||||
uses: Naturalclar/issue-action@v2.0.2
|
||||
with:
|
||||
title-or-body: both
|
||||
github-token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
parameters: >
|
||||
[
|
||||
{"keywords": ["Addition"], "labels": ["Addition"] },
|
||||
{"keywords": ["Amendment"], "labels": ["Amendment"] },
|
||||
{"keywords": ["Removal"], "labels": ["Removal"] },
|
||||
{"keywords": ["Spelling or Grammar"], "labels": ["Grammar"] },
|
||||
{"keywords": ["Website Update"], "labels": ["Website"] },
|
||||
{"keywords": ["Misc"], "labels": ["Misc"] }
|
||||
]
|
||||
20
.github/workflows/spell-check.yml
vendored
20
.github/workflows/spell-check.yml
vendored
|
|
@ -1,20 +0,0 @@
|
|||
# Spell check newly added content, when PR opened and, put typo list as comment
|
||||
name: ✏️ Spell Check
|
||||
on: [pull_request]
|
||||
jobs:
|
||||
misspell:
|
||||
name: runner / misspell
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2
|
||||
- name: Run Spell Check 📝
|
||||
uses: reviewdog/action-misspell@v1
|
||||
with:
|
||||
github_token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
locale: US
|
||||
level: info
|
||||
reporter: github-pr-review
|
||||
path: .
|
||||
filter_mode: added
|
||||
fail_on_error: false
|
||||
17
.github/workflows/sync-mirror.yml
vendored
17
.github/workflows/sync-mirror.yml
vendored
|
|
@ -1,17 +0,0 @@
|
|||
# Pushes the contents of the repo to the Codeberg mirror
|
||||
name: 🪞 Mirror to Codeberg
|
||||
on:
|
||||
workflow_dispatch: # Manual dispatch
|
||||
schedule:
|
||||
- cron: '0 5 * * 6'
|
||||
jobs:
|
||||
codeberg:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pixta-dev/repository-mirroring-action@v1
|
||||
with:
|
||||
target_repo_url: "git@codeberg.org:alicia/awesome-privacy.git"
|
||||
ssh_private_key: ${{ secrets.CODEBERG_SSH }}
|
||||
40
.github/workflows/ticket-check.yml
vendored
40
.github/workflows/ticket-check.yml
vendored
|
|
@ -1,40 +0,0 @@
|
|||
# Checks newly opened issues contain enough info, and follow the required format
|
||||
name: 🎫 Issue Validator
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
jobs:
|
||||
check-title:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Check Default Title
|
||||
if: "endsWith(github.event.issue.title, '<title>')"
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Please ensure that your ticket has an appropriate title
|
||||
- name: Check Title Contains Categroy
|
||||
if: "!(startsWith(github.event.issue.title, '[') && contains(github.event.issue.title, ']'))"
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Please ensure that your ticket's title is preceded with a category.
|
||||
For example, `[ADDITION]`, `[AMENDMENT]`, `[REMOVAL]` or `[QUESTION]`.
|
||||
- name: Check Quality Checklist
|
||||
if: "contains(github.event.issue.body, '[ ]') || !(contains(github.event.issue.body, '[X]') || contains(github.event.issue.body, '[x]'))"
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
Please ensure that you've followed the issue template fully.
|
||||
It's important that you complete the quality & transparency checklist.
|
||||
|
||||
|
||||
|
||||
|
||||
90
.github/workflows/validate-pr.yml
vendored
90
.github/workflows/validate-pr.yml
vendored
|
|
@ -1,90 +0,0 @@
|
|||
# Checks that PR title conform to contributing standards (or at least !== Update README.md)
|
||||
name: ⛳ Validate PR
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
BASE_MSG: >+
|
||||
Thanks for contributing to Awesome-Privacy! Your pull request will be reviewed shortly.
|
||||
|
||||
In the meantime, please be sure that you have read, and complied with the guidelines outlined in the
|
||||
[Contributing Docs](https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md).
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate Title is not Default
|
||||
if: "contains(github.event.pull_request.title, 'Update README.md')"
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
Hi @${{ github.actor }},
|
||||
Please update your pull request, to include a more descriptive title.
|
||||
|
||||
- name: Validate Checklist is Completed
|
||||
if: >
|
||||
contains(github.event.pull_request.body, '[ ]') ||
|
||||
!(contains(github.event.pull_request.body, '[X]') || contains(github.event.pull_request.body, '[x]'))
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
Hello @${{ github.actor }} 👋
|
||||
${{ env.BASE_MSG }}
|
||||
⚠️ It looks like you've not complete the quality and transparency checklist.
|
||||
|
||||
- name: Validate Affiliation Section is Present
|
||||
if: >
|
||||
!contains(github.event.pull_request.body, 'Affiliation')
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
Hello @${{ github.actor }} 👋
|
||||
${{ env.BASE_MSG }}
|
||||
⚠️ You must indicate if you are affiliated with any software modified by this PR.
|
||||
If not applicable, you may set this field to N/A.
|
||||
|
||||
- name: Validate Category
|
||||
if: >
|
||||
contains(github.event.pull_request.body, 'Addition / Amendment / Removal / Spelling or Grammar / Website Update / Misc') ||
|
||||
!(
|
||||
contains(github.event.pull_request.body, 'Addition') ||
|
||||
contains(github.event.pull_request.body, 'Amendment') ||
|
||||
contains(github.event.pull_request.body, 'Removal') ||
|
||||
contains(github.event.pull_request.body, 'Spelling or Grammar') ||
|
||||
contains(github.event.pull_request.body, 'Website Update') ||
|
||||
contains(github.event.pull_request.body, 'Misc')
|
||||
)
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
Hello @${{ github.actor }} 👋
|
||||
${{ env.BASE_MSG }}
|
||||
⚠️ You must specify a category
|
||||
Either: `Addition`, `Amendment`, `Removal`, `Spelling or Grammar`, `Website Update`, or `Misc`.
|
||||
|
||||
- name: Validate Supporting Material is Present
|
||||
if: >
|
||||
!contains(github.event.pull_request.body, 'Supporting Material')
|
||||
uses: peter-evans/create-or-update-comment@v2
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body: |
|
||||
Hello @${{ github.actor }} 👋
|
||||
${{ env.BASE_MSG }}
|
||||
⚠️ If applicable, please ensure you've provided supporting material.
|
||||
|
||||
|
||||
96
.github/workflows/web-checks.yml
vendored
Normal file
96
.github/workflows/web-checks.yml
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
name: Web Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['web/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: 🧼 Lint
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: web/.nvmrc
|
||||
cache: yarn
|
||||
cache-dependency-path: web/yarn.lock
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint
|
||||
|
||||
format:
|
||||
name: 💅 Format
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: web/.nvmrc
|
||||
cache: yarn
|
||||
cache-dependency-path: web/yarn.lock
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn format:check
|
||||
|
||||
typecheck:
|
||||
name: 🧩 Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: web/.nvmrc
|
||||
cache: yarn
|
||||
cache-dependency-path: web/yarn.lock
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn typecheck
|
||||
|
||||
test:
|
||||
name: 🧪 Test
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: web/.nvmrc
|
||||
cache: yarn
|
||||
cache-dependency-path: web/yarn.lock
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn test
|
||||
|
||||
summary:
|
||||
name: 💬 Summary
|
||||
if: always()
|
||||
needs: [lint, format, typecheck, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build results table
|
||||
run: |
|
||||
em() { if [ "$1" = "success" ]; then echo "pass ✅"; else echo "FAIL ❌"; fi; }
|
||||
line() { echo "| $1 | \`$2\` | $(em "$3") |"; }
|
||||
|
||||
{
|
||||
echo "## Web Checks Summary"
|
||||
echo ""
|
||||
echo "| Check | Command | Result |"
|
||||
echo "|-------|---------|--------|"
|
||||
line "Lint" "yarn lint" "${{ needs.lint.result }}"
|
||||
line "Format" "yarn format:check" "${{ needs.format.result }}"
|
||||
line "Typecheck" "yarn typecheck" "${{ needs.typecheck.result }}"
|
||||
line "Test" "yarn test" "${{ needs.test.result }}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
18
.github/workflows/welcome-non-stargazers.yml
vendored
18
.github/workflows/welcome-non-stargazers.yml
vendored
|
|
@ -1,18 +0,0 @@
|
|||
name: ⭐ Hello non-Stargazers
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
jobs:
|
||||
check-user:
|
||||
if: ${{ github.event.comment.author_association != 'CONTRIBUTOR' }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Add comment to issues opened by non-stargazers
|
||||
steps:
|
||||
- name: comment
|
||||
uses: qxip/please-star-light@v4
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
autoclose: false
|
||||
message: |
|
||||
If you're enjoying Awesome-Privacy, consider dropping us a ⭐<br>
|
||||
_<sub>🤖 I'm a bot, and this message was automated</sub>_
|
||||
2
Makefile
2
Makefile
|
|
@ -34,7 +34,7 @@ WEB_DIR := web
|
|||
|
||||
# Targets for lib/
|
||||
install_lib_deps:
|
||||
$(PYTHON) -m pip install -r $(LIB_DIR)/requirements.txt
|
||||
$(PYTHON) -m pip install -q -r $(LIB_DIR)/requirements.txt
|
||||
|
||||
gen_readme: install_lib_deps
|
||||
$(PYTHON) $(LIB_DIR)/awesome-privacy-readme-gen.py
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ categories:
|
|||
Free for self-hosted data (or $3/ month hosted). Be aware that 1Password
|
||||
is not fully open source, but they do regularly publish results of their
|
||||
independent [security audits](https://support.1password.com/security-assessments),
|
||||
and they have a solid reputation for transparently disclosing and fixing vulnerabilities
|
||||
and they have a solid reputation for transparently disclosing and fixing vulnerabilities
|
||||
furtherInfo: >
|
||||
**Other Open Source PM**: [Buttercup](https://buttercup.pw), [Clipperz](https://clipperz.is),
|
||||
[Pass](https://www.passwordstore.org), [Padloc](https://padloc.app), [TeamPass](https://teampass.net),
|
||||
|
|
@ -252,11 +252,39 @@ categories:
|
|||
iosApp: https://apps.apple.com/us/app/ente-auth/id6444121398
|
||||
androidApp: io.ente.auth
|
||||
description: |
|
||||
Ente Auth is a free and open-source app which stores and generates TOTP tokens.
|
||||
It can be used with an online account to backup and sync your tokens across your
|
||||
devices (and access them via a web interface) in a secure, end-to-end encrypted
|
||||
Ente Auth is a free and open-source app which stores and generates TOTP tokens.
|
||||
It can be used with an online account to backup and sync your tokens across your
|
||||
devices (and access them via a web interface) in a secure, end-to-end encrypted
|
||||
fashion. It can also be used offline on a single device with no account necessary.
|
||||
|
||||
- name: Bitwarden Authenticator
|
||||
url: https://bitwarden.com/products/authenticator/
|
||||
icon: https://avatars.githubusercontent.com/u/15990069
|
||||
openSource: true
|
||||
github: bitwarden/ios
|
||||
tosdrId: 5256
|
||||
iosApp: https://apps.apple.com/app/bitwarden-authenticator/id6497335175
|
||||
androidApp: com.bitwarden.authenticator
|
||||
description: |
|
||||
Bitwarden Authenticator is a free and open-source app which stores and generates
|
||||
time-based codes for multi-factor authentication. It can be used with an online
|
||||
account to backup and sync your tokens across your devices (and access them via
|
||||
a web interface) in a secure, end-to-end encrypted fashion. It can also be used
|
||||
offline on a single device with no account necessary.
|
||||
|
||||
- name: Proton Authenticator
|
||||
url: https://proton.me/authenticator
|
||||
icon: https://raw.githubusercontent.com/protonpass/android-pass/refs/heads/main/metadata/en-US/images/icon.png
|
||||
openSource: true
|
||||
github: protonpass/android-authenticator
|
||||
tosdrId: 491
|
||||
iosApp: https://apps.apple.com/us/app/proton-authenticator/id6741758667
|
||||
androidApp: proton.android.authenticator
|
||||
description: |
|
||||
Proton Authenticator is free, open source, and available for both iOS and Android.
|
||||
A Proton account is required to use Proton Authenticator.
|
||||
Existing 2FA codes can be imported from other popular apps such as Google Authenticator and LastPass.
|
||||
|
||||
furtherInfo: >
|
||||
Check which websites support multi-factor authentication: [2fa.directory](https://2fa.directory/)
|
||||
notableMentions: >
|
||||
|
|
@ -265,7 +293,7 @@ categories:
|
|||
[Etopa](https://play.google.com/store/apps/details?id=de.ltheinrich.etopa) *(Android)*<br>
|
||||
For KeePass users, [TrayTop](https://keepass.info/plugins.html#traytotp)
|
||||
is a plugin for managing TOTP's - offline and compatible with Windows, Mac and Linux.
|
||||
|
||||
|
||||
#############################
|
||||
###### File Encryption ######
|
||||
#############################
|
||||
|
|
@ -309,9 +337,10 @@ categories:
|
|||
- name: Picocrypt
|
||||
github: Picocrypt/Picocrypt
|
||||
icon: https://avatars.githubusercontent.com/u/171401041
|
||||
url: ''
|
||||
description: |
|
||||
Picocrypt is a very small (hence Pico), very simple, yet very secure encryption tools
|
||||
that you can use to protect your files. It's designed to be the go-to tool for encryption,
|
||||
that you can use to protect your files. It's designed to be the go-to tool for encryption,
|
||||
with a focus on security, simplicity, and reliability.
|
||||
|
||||
wordOfWarning: >
|
||||
|
|
@ -393,7 +422,7 @@ categories:
|
|||
Brave also has Tor built-in, when you open up a private tab/ window.
|
||||
|
||||
- name: Firefox
|
||||
url: https://www.mozilla.org/firefox
|
||||
url: https://www.firefox.com/
|
||||
icon: https://www.mozilla.org/media/protocol/img/logos/firefox/logo.fedb52c912d6.svg
|
||||
openSource: true
|
||||
tosdrId: 188
|
||||
|
|
@ -440,7 +469,7 @@ categories:
|
|||
After installing, check the privacy & security settings, and update the configuration to
|
||||
something that you are comfortable with. 12Bytes maintains a comprehensive guide on
|
||||
[Firefox Configuration for Privacy and Performance](https://codeberg.org/12bytes/firefox-config-guide)
|
||||
|
||||
|
||||
############################
|
||||
###### Search Engines ######
|
||||
############################
|
||||
|
|
@ -511,6 +540,17 @@ categories:
|
|||
description: |
|
||||
British search engine providing independent and unbiased search results using
|
||||
its own crawler. Has a zero tracking policy (it is not open source)
|
||||
|
||||
- name: Uruky
|
||||
url: https://uruky.com
|
||||
icon: https://uruky.com/public/images/favicon.svg
|
||||
iosApp: https://apps.apple.com/us/app/uruky-private-ad-free-search/id6758864380
|
||||
description: |
|
||||
Uruky is an ad-free, private search engine focused on personalization. It uses,
|
||||
among other providers, Mojeek and Marginalia. It is EU-based and does not keep
|
||||
or track any personal data (it is not 100% open source, but after 12 months as
|
||||
a paying customer, you get a copy of the source code).
|
||||
|
||||
notableMentions:
|
||||
- name: MetaGear
|
||||
url: https://metager.org
|
||||
|
|
@ -524,8 +564,8 @@ categories:
|
|||
and self-hostable, although using a [public instance](https://searx.space)
|
||||
has the benefit of not singling out your queries to the engines used.
|
||||
A fork of the original [Searx](https://searx.github.io/searx/).
|
||||
|
||||
|
||||
|
||||
|
||||
- name: Communication
|
||||
sections:
|
||||
#################################
|
||||
|
|
@ -581,10 +621,10 @@ categories:
|
|||
subreddit: SimpleXChat
|
||||
description: |
|
||||
Simplex is gaining popularity as a secure and private messaging app renowned
|
||||
for its robust encryption protocol without user IDs or phone numbers and this improves your privacy.
|
||||
for its robust encryption protocol without user IDs or phone numbers and this improves your privacy.
|
||||
Simplex offers instant messaging, supports media attachments and voice and video calls.
|
||||
Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's
|
||||
preferences for convenience, security, and accessibility.
|
||||
Additionally, it is cross-platform, open-source, and completely free, aligning with the modern user's
|
||||
preferences for convenience, security, and accessibility.
|
||||
Learn more about the [Security Policy](https://simplex.chat/security/).
|
||||
|
||||
- name: XMPP
|
||||
|
|
@ -615,7 +655,7 @@ categories:
|
|||
an open specification and Simple pragmatic RESTful HTTP/JSON API it makes it easy
|
||||
to integrates with existing 3rd party IDs to authenticate and discover users, as
|
||||
well as to build apps on top of it.
|
||||
notableMentions:
|
||||
notableMentions:
|
||||
- name: Chat Secure
|
||||
url: https://chatsecure.org
|
||||
- name: KeyBase
|
||||
|
|
@ -649,7 +689,7 @@ categories:
|
|||
be used to communicate any sensitive data.
|
||||
[Wire](https://wire.com/) has also been removed, due to
|
||||
a [recent acquisition](https://blog.privacytools.io/delisting-wire/)
|
||||
|
||||
|
||||
###########################
|
||||
###### P2P Messaging ######
|
||||
###########################
|
||||
|
|
@ -724,7 +764,7 @@ categories:
|
|||
url: https://github.com/Bitmessage/PyBitmessage
|
||||
- name: RetroShare
|
||||
url: https://retroshare.cc
|
||||
|
||||
|
||||
#############################
|
||||
###### Encrypted Email ######
|
||||
#############################
|
||||
|
|
@ -740,6 +780,24 @@ categories:
|
|||
For a more details comparison of email providers, see
|
||||
[email-comparison.as93.net](https://email-comparison.as93.net/)
|
||||
services:
|
||||
- name: Forward Email
|
||||
url: https://forwardemail.net
|
||||
icon: https://forwardemail.net/img/android-chrome-192x192.png
|
||||
github: forwardemail/free-email-forwarding
|
||||
openSource: true
|
||||
subreddit: forwardemail
|
||||
description: |
|
||||
A 100% open-source, privacy-focused email service with quantum-safe encrypted
|
||||
SQLite mailboxes (sandboxed and portable). Supports IMAP, POP3, SMTP, CalDAV
|
||||
(calendars), and CardDAV (contacts). Features include
|
||||
[OpenPGP/MIME and E2EE](https://forwardemail.net/en/faq#do-you-support-openpgpmime-end-to-end-encryption-e2ee-and-web-key-directory-wkd),
|
||||
[Web Key Directory (WKD)](https://forwardemail.net/en/faq#do-you-support-openpgpmime-end-to-end-encryption-e2ee-and-web-key-directory-wkd),
|
||||
and [quantum-resistant encryption](https://forwardemail.net/blog/docs/best-quantum-safe-encrypted-email-service)
|
||||
using ChaCha20-Poly1305. Offers a free plan (forwarding only), Enhanced Protection
|
||||
at $3/month (10 GB storage, unlimited domains/aliases), Team at $9/month, and
|
||||
Enterprise at $250/month. Additional storage available. Can be
|
||||
[self-hosted via Docker](https://forwardemail.net/en/faq#do-you-support-self-hosting).
|
||||
All code is available on [GitHub](https://github.com/forwardemail).
|
||||
- name: ProtonMail
|
||||
url: https://protonmail.com
|
||||
icon: https://proton.me/favicons/android-chrome-192x192.png
|
||||
|
|
@ -774,19 +832,12 @@ categories:
|
|||
custom domains (starting at $3/month). Tuta
|
||||
[does not use OpenPGP](https://tuta.com/blog/posts/differences-email-encryption/)
|
||||
like other encrypted mail providers, instead they use a standardized, hybrid method
|
||||
consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048
|
||||
consisting of symmetrical and asymmetrical algorithms (with AES256, and RSA 2048
|
||||
or ECC (x25519) and Kyber-1024). This causes compatibility issues when communicating with contacts
|
||||
using PGP. But it does allow them to encrypt much more of the header data (body,
|
||||
attachments, subject lines, and sender names etc) which PGP mail providers cannot do. The recent upgrades
|
||||
to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks
|
||||
to Tuta's encryption algorithm makes data stored and sent with their service safe against attacks
|
||||
posed by quantum computers.
|
||||
- name: Forward Email
|
||||
url: https://forwardemail.net
|
||||
icon: https://forwardemail.net/img/android-chrome-192x192.png
|
||||
github: forwardemail/free-email-forwarding
|
||||
openSource: true
|
||||
description: |
|
||||
An open source, privacy-focused, encrypted email service supporting SMTP, IMAP, and API access
|
||||
- name: Mailfence
|
||||
url: https://mailfence.com?src=digitald
|
||||
icon: https://mailfence.com/c/mailfence/images/favicon/android-chrome-192x192.png
|
||||
|
|
@ -1093,7 +1144,7 @@ categories:
|
|||
url: https://www.smspool.net
|
||||
icon: https://i.ibb.co/2t4MBFj/apple-touch-icon.png
|
||||
description: |
|
||||
Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers.
|
||||
Don't feel comfortable giving out your phone number? Protect your online identity by using our one-time-use non-VoIP phone numbers.
|
||||
We support over 50+ countries and support over 300+ services.
|
||||
androidApp: com.smspool.app
|
||||
iosApp: https://apps.apple.com/app/smspool/id6474617801
|
||||
|
|
@ -1134,6 +1185,14 @@ categories:
|
|||
Anonymous SMS service able to activate accounts. Accessible over web, CLI, or
|
||||
email. Pricing starts at $3.60 / month. The service is in beta as of 2022.
|
||||
|
||||
- name: PikaSim
|
||||
url: https://pikasim.com
|
||||
icon: https://pikasim.com/img/pika-transparent.png
|
||||
acceptsCrypto: true
|
||||
description: |
|
||||
Privacy-focused eSIM provider for 170+ countries, with instant eSIM delivery.
|
||||
No account, ID, email needed, and accepts crypto via a self-hosted BTCPay Server.
|
||||
|
||||
################################
|
||||
###### Team Collaboration ######
|
||||
################################
|
||||
|
|
@ -1441,7 +1500,7 @@ categories:
|
|||
Displays a country flag depicting the location of the current website's server, which can be useful to know at a glance.
|
||||
Click icon for more tools such as site safety checks, whois, validation etc **Download**:
|
||||
[Firefox](https://addons.mozilla.org/en-US/firefox/addon/flagfox/)
|
||||
|
||||
|
||||
- name: Lightbeam
|
||||
url: https://mozilla.github.io/lightbeam/
|
||||
github: mozilla/lightbeam-we
|
||||
|
|
@ -1512,7 +1571,7 @@ categories:
|
|||
url: https://addons.mozilla.org/en-US/firefox/addon/crxviewer
|
||||
description: >
|
||||
A handy extension for viewing the source code of another browser extension,
|
||||
which is a useful tool for verifying the code does what it says
|
||||
which is a useful tool for verifying the code does what it says
|
||||
wordOfWarning: |
|
||||
- Having many extensions installed raises entropy, causing your fingerprint to be more unique, hence making tracking easier.
|
||||
- Much of the functionality of the above addons can be applied without installing anything, by configuring browser settings yourself. For Firefox this is done in the user.js
|
||||
|
|
@ -1998,6 +2057,17 @@ categories:
|
|||
A court-proven VPN service with support for Wireguard and OpenVPN support, and optional
|
||||
ad-blocking. Running on dedicated hardware, with no hard drives.
|
||||
|
||||
- name: Party VPN
|
||||
url: https://partyvpn.com
|
||||
icon: https://partyvpn.com/icon.png
|
||||
securityAudited: false
|
||||
acceptsCrypto: true
|
||||
discordInvite: https://discord.gg/6GRddHhW2d
|
||||
description: |
|
||||
Crypto-native VPN with pay-as-you-go pricing. Accepts cryptocurrency only, including Monero.
|
||||
WireGuard and AmneziaWG protocols, no-logs policy, REST API for developers. No
|
||||
subscriptions or credit cards.
|
||||
|
||||
wordOfWarning: |
|
||||
- *A VPN does not make you anonymous - it merely changes your public IP address to that of your VPN provider, instead of your ISP. Your browsing session can still be linked back to your real identity either through your system details (such as user agent, screen resolution even typing patterns), cookies / session storage, or by the identifiable data that you enter. [Read more about fingerprinting](https://pixelprivacy.com/resources/browser-fingerprinting/)*
|
||||
- *Logging - If you choose to use a VPN because you do not agree with your ISP logging your full browsing history, then it is important to keep in mind that your VPN provider can see (and mess with) all your traffic. Many VPNs claim not to keep logs, but you cannot be certain of this ([VPN leaks](https://vpnleaks.com/)). See [this article](https://gist.github.com/joepie91/5a9909939e6ce7d09e29) for more*
|
||||
|
|
@ -2021,7 +2091,7 @@ categories:
|
|||
See [Streisand](https://github.com/StreisandEffect/streisand), to learn more, and get started with running a VPN.
|
||||
[Digital Ocean](https://m.do.co/c/3838338e7f79) provides flexible,
|
||||
secure and easy Linux VMs, (from $0.007/hour or $5/month),
|
||||
Here is a [1-click install script](http://dovpn.carlfriess.com/)for
|
||||
Here is a [1-click install script](http://dovpn.carlfriess.com/)for
|
||||
on [Digital Ocean](https://m.do.co/c/3838338e7f79), by Carl Friess.
|
||||
|
||||
Recently distributed self-hosted solutions for running your own VPNs have
|
||||
|
|
@ -2181,8 +2251,8 @@ categories:
|
|||
Tor, I2P and Freenet are all anonymity networks - but they work very differently and each is good for specific purposes.
|
||||
So a good and viable solution would be to use all of them, for different tasks.
|
||||
*You can read more about how I2P compares to Tor, [here](https://blokt.com/guides/what-is-i2p-vs-tor-browser)*
|
||||
|
||||
|
||||
|
||||
|
||||
#####################
|
||||
###### Proxies ######
|
||||
#####################
|
||||
|
|
@ -2301,7 +2371,7 @@ categories:
|
|||
Mullvads public DNS with QNAME minimization and basic ad blocking.
|
||||
It has been audited by the security experts at Assured.
|
||||
You can use this privacy-enhancing service even if you don't use Mullvad.
|
||||
|
||||
|
||||
#########################
|
||||
###### DNS Clients ######
|
||||
#########################
|
||||
|
|
@ -2621,7 +2691,7 @@ categories:
|
|||
|
||||
Some VPNs have ad-tracking blocking features, such as
|
||||
[TrackStop with PerfectPrivacy](https://www.perfect-privacy.com/en/features/trackstop?a_aid=securitychecklist).
|
||||
|
||||
|
||||
[Private Internet Access](https://www.privateinternetaccess.com/),
|
||||
[CyberGhost](https://www.cyberghostvpn.com/),
|
||||
[PureVPN](https://www.anrdoezrs.net/click-9242873-13842740),
|
||||
|
|
@ -2860,7 +2930,7 @@ categories:
|
|||
[5 eyes](https://en.wikipedia.org/wiki/Five_Eyes) (Australia, Canada, New Zealand, US and UK)
|
||||
and [other international cooperatives](https://en.wikipedia.org/wiki/Five_Eyes#Other_international_cooperatives)
|
||||
who have legal right to view your data.
|
||||
|
||||
|
||||
###############################
|
||||
###### Domain Registrars ######
|
||||
###############################
|
||||
|
|
@ -2898,7 +2968,7 @@ categories:
|
|||
followWith: Web
|
||||
description: |
|
||||
Free DNS hosting provider designed with security in mind, and running
|
||||
on purely open source software. deSEC is backed and funded by SSE.
|
||||
on purely open source software. deSEC is backed and funded by SSE.
|
||||
|
||||
|
||||
###########################
|
||||
|
|
@ -2985,7 +3055,7 @@ categories:
|
|||
All notes are saved individually as .md files, making them easy to manage.
|
||||
No mobile app, built-in cloud-sync, encryption or web UI. But due to the structure of the files,
|
||||
it is easy to use your own cloud sync provider, and additional features are provided through extensions.
|
||||
|
||||
|
||||
- name: Joplin
|
||||
url: https://joplinapp.org
|
||||
github: laurent22/joplin
|
||||
|
|
@ -3070,7 +3140,7 @@ categories:
|
|||
|
||||
notableMentions: |
|
||||
If you are already tied into Evernote, One Note etc, then [SafeRoom](https://www.getsaferoom.com)
|
||||
is a utility that encrypts your entire notebook, before it is uploaded to the cloud.
|
||||
is a utility that encrypts your entire notebook, before it is uploaded to the cloud.
|
||||
|
||||
[Org Mode](https://orgmode.org) is a mode for [GNU Emacs](https://www.gnu.org/software/emacs/)
|
||||
dedicated to working with the Org markup format. Org can be thought of as
|
||||
|
|
@ -3130,6 +3200,17 @@ categories:
|
|||
A key benefit the wide range of plug-ins in the NextCloud App Store,
|
||||
maintained by the community. NextCloud was a hard fork off OwnCloud.
|
||||
|
||||
- name: bewCloud
|
||||
url: https://bewcloud.com
|
||||
icon: https://bewcloud.com/favicon.svg
|
||||
followWith: Web
|
||||
github: bewcloud/bewcloud
|
||||
openSource: true
|
||||
description: |
|
||||
Modern and simpler alternative to Nextcloud/ownCloud crafted with TypeScript.
|
||||
Unifies file management, sync, sharing, notes, RSS, expenses, calendars,
|
||||
contacts, and photos, with MFA, WebDAV, CalDAV, CardDAV, SSO, and more.
|
||||
|
||||
notableMentions: |
|
||||
Alternatively, consider a headless utility such as [Duplicacy](https://duplicacy.com)
|
||||
or [Duplicity](http://duplicity.nongnu.org).
|
||||
|
|
@ -3320,7 +3401,7 @@ categories:
|
|||
using [Web Torrent](https://webtorrent.io).
|
||||
|
||||
For specifically transferring images, [Up1](https://github.com/Upload/Up1) is a good self-hosted option, with client-side encryption.
|
||||
|
||||
|
||||
Finally [PsiTransfer](https://github.com/psi-4ward/psitransfer) is a feature-rich, self-hosted file drop, using streams.
|
||||
|
||||
|
||||
|
|
@ -3369,7 +3450,7 @@ categories:
|
|||
description: |
|
||||
Simple bookmark manager written in Go, intended to be a clone of Pocket, it has both a simple and clean web
|
||||
interface as well as a CLI. Shiori has easy import/ export, is portable and has webpage archiving features.
|
||||
|
||||
|
||||
notableMentions: |
|
||||
[Ymarks](https://ymarks.org) is a C-based self-hosted bookmark synchronization
|
||||
server and [Chrome](https://chrome.google.com/webstore/detail/ymarks/gefignhaigoigfjfbjjobmegihhaacfi) extension.
|
||||
|
|
@ -3427,7 +3508,7 @@ categories:
|
|||
notableMentions: |
|
||||
[Apache OpenMeetings](https://openmeetings.apache.org) provides self-hosted
|
||||
video-conferencing, chat rooms, file server and tools for meetings.
|
||||
|
||||
|
||||
[together.brave.com](https://together.brave.com) is Brave's Jitsi Fork.
|
||||
|
||||
For remote learning, [BigBlueButton](https://bigbluebutton.org) is self-hosted conference call software,
|
||||
|
|
@ -3484,10 +3565,10 @@ categories:
|
|||
it is not open source, and although there is a free version, a license
|
||||
is required to access all features. VMWare performs very well when running
|
||||
on a server, with hundreds of hosts and users.
|
||||
|
||||
|
||||
For Mac users, [Parallels](https://www.parallels.com/uk/) is a popular
|
||||
option which performs really well, but again is not open source.
|
||||
|
||||
|
||||
For Windows users, there's
|
||||
[Hyper-V](https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v),
|
||||
which is a native Windows product, developed by Microsoft.
|
||||
|
|
@ -3556,7 +3637,7 @@ categories:
|
|||
description: |
|
||||
iOS app for encrypting/ decrypting text.
|
||||
Has native keyboard integration, keychain support and app integrations which makes it quick to use in any app.
|
||||
|
||||
|
||||
|
||||
- name: FlowCrypt
|
||||
url: https://flowcrypt.com
|
||||
|
|
@ -3635,10 +3716,10 @@ categories:
|
|||
|
||||
Alternatively, with [ImageMagic](https://imagemagick.org) installed, just run
|
||||
`convert -strip path/to/image.png` to remove all metadata.
|
||||
|
||||
|
||||
If you have [GIMP](https://www.gimp.org) installed, then just go to `File --> Export As --> Export --> Advanced Options --> Uncheck the "Save EXIF data" option`.
|
||||
|
||||
Often you need to perform meta data removal programmatically, as part of a script or automation process.
|
||||
Often you need to perform meta data removal programmatically, as part of a script or automation process.
|
||||
- GoLang: [go-exif](https://github.com/dsoprea/go-exif) by @dsoprea
|
||||
- JS: [exifr](https://github.com/MikeKovarik/exifr) by @MikeKovarik
|
||||
- Python: [Piexif](https://github.com/hMatoba/Piexif) by @hMatoba
|
||||
|
|
@ -3654,7 +3735,7 @@ categories:
|
|||
[not remove it](https://uk.norton.com/internetsecurity-privacy-is-my-personal-data-really-gone-when-its-deleted-from-a-device.html)
|
||||
from the disk, and recovering deleted files is a
|
||||
[simple task](https://www.lifewire.com/how-to-recover-deleted-files-2622870).
|
||||
|
||||
|
||||
Therefore, to protect your privacy, you should erase/ overwrite data from
|
||||
the disk, before you destroy, sell or give away a hard drive.
|
||||
services:
|
||||
|
|
@ -3743,12 +3824,12 @@ categories:
|
|||
Or [badblocks](https://linux.die.net/man/8/badblocks) which is intended to search for all bad blocks,
|
||||
but can also be used to write zeros to a disk,
|
||||
by running `sudo badblocks -wsv /dev/sdd`.
|
||||
|
||||
|
||||
An effective method of erasing an SSD, it to use [hdparm](https://en.wikipedia.org/wiki/Hdparm)
|
||||
to issue a [secure erase](https://en.wikipedia.org/wiki/Parallel_ATA#HDD_passwords_and_security)
|
||||
command, to your target storage device,
|
||||
for this, see step-by-step instructions via: [wiki.kernel.org](https://ata.wiki.kernel.org/index.php/ATA_Secure_Erase).
|
||||
|
||||
|
||||
Finally, [srm](https://www.systutorials.com/docs/linux/man/1-srm/) can be
|
||||
use to securely remove files or directories, just run `srm -zsv /path/to/file`
|
||||
for a single pass over.
|
||||
|
|
@ -3766,7 +3847,7 @@ categories:
|
|||
If you are an Android user, your device has Google built-in at its core.
|
||||
[Google tracks you](https://digitalcontentnext.org/blog/2018/08/21/google-data-collection-research/),
|
||||
collecting a wealth of information, and logging your every move.
|
||||
|
||||
|
||||
A [custom ROM](https://en.wikipedia.org/wiki/List_of_custom_Android_distributions),
|
||||
is an open source, usually Google-free mobile OS that can be flashed to your device.
|
||||
services:
|
||||
|
|
@ -3775,7 +3856,7 @@ categories:
|
|||
github: GrapheneOS/hardened_malloc
|
||||
icon: https://grapheneos.org/apple-touch-icon.png
|
||||
description: |
|
||||
GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay.
|
||||
GrapheneOS is an open source privacy and security focused mobile OS with Android app compatibility. Developed by Daniel Micay.
|
||||
GrapheneOS is a young project, and currently only supports Pixel devices, partially due to their strong hardware security.
|
||||
|
||||
- name: CalyxOS
|
||||
|
|
@ -3784,8 +3865,8 @@ categories:
|
|||
github: CalyxOS/calyxos
|
||||
tosdrId: 2558
|
||||
description: |
|
||||
CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users.
|
||||
Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently
|
||||
CalyxOS is an free and open source Android mobile operating system that puts privacy and security into the hands of everyday users.
|
||||
Plus, proactive security recommendations and automatic updates take the guesswork out of keeping your personal data personal. Also currently
|
||||
only supports Pixel devices and Xiaomi Mi A2 with Fairphone 4, OnePlus 8T, OnePlus 9 test builds available. Developed by the Calyx Foundation.
|
||||
|
||||
- name: DivestOS
|
||||
|
|
@ -3794,8 +3875,8 @@ categories:
|
|||
github: Divested-Mobile/DivestOS-Build
|
||||
tosdrId: 2550
|
||||
description: |
|
||||
DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of
|
||||
discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained
|
||||
DivestOS is a vastly diverged unofficial more secure and private soft fork of LineageOS. DivestOS primary goal is prolonging the life-span of
|
||||
discontinued devices, enhancing user privacy, and providing a modest increase of security where/when possible. Project is developed and maintained
|
||||
solely by Tad (SkewedZeppelin) since 2014.
|
||||
|
||||
- name: LineageOS
|
||||
|
|
@ -3804,17 +3885,17 @@ categories:
|
|||
github: LineageOS/android
|
||||
tosdrId: 7188
|
||||
description: |
|
||||
A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained,
|
||||
A free and open-source operating system for various devices, based on the Android mobile platform - Lineage is light-weight, well maintained,
|
||||
supports a wide range of devices, and comes bundled with Privacy Guard.
|
||||
|
||||
notableMentions: |
|
||||
[Replicant OS](https://www.replicant.us/) is a fully-featured distro,
|
||||
with an emphasis on freedom, privacy and security.
|
||||
|
||||
|
||||
[OmniRom](https://www.omnirom.org/),
|
||||
[Resurrection Remix OS](https://resurrectionremix.com/)
|
||||
and [Paranoid Android](http://paranoidandroid.co/) are also popular options.
|
||||
|
||||
|
||||
Alternatively, [Ubuntu Touch](https://ubports.com/) is a Linux (Ubuntu)- based OS.
|
||||
It is secure by design and runs on almost any device, - but it does fall short when it comes to the app store.
|
||||
|
||||
|
|
@ -3981,7 +4062,7 @@ categories:
|
|||
some technical knowledge to get started with, but once setup should perform
|
||||
just as any other Windows 10 system. Note that you should only download the
|
||||
LTSC ISO from the Microsoft's
|
||||
[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise)
|
||||
[official page](https://www.microsoft.com/en-in/evalcenter/evaluate-windows-10-enterprise)
|
||||
|
||||
|
||||
#### Improve the Security and Privacy of your current OS
|
||||
|
|
@ -4210,8 +4291,8 @@ categories:
|
|||
and check reviews/ forums.
|
||||
Create a system restore point, before making any significant changes to
|
||||
your OS (such as disabling core features).
|
||||
|
||||
From a security and privacy perspective, Linux may be a better option.
|
||||
|
||||
From a security and privacy perspective, Linux may be a better option.
|
||||
|
||||
notableMentions: |
|
||||
See also these lists:
|
||||
|
|
@ -4284,7 +4365,7 @@ categories:
|
|||
that offer free solutions, even if you pay for the premium package.
|
||||
This includes (but not limited to) Avast, AVG, McAfee and Kasperky.
|
||||
For AV to be effective, it needs intermate access to all areas of your PC,
|
||||
so it is important to go with a trusted vendor, and monitor its activity closely.
|
||||
so it is important to go with a trusted vendor, and monitor its activity closely.
|
||||
|
||||
|
||||
- name: Development
|
||||
|
|
@ -4339,7 +4420,7 @@ categories:
|
|||
[reputation](https://srlabs.de/bites/smart-spies) when it comes to protecting
|
||||
consumers privacy, there have been
|
||||
[many recent breaches](https://www.theverge.com/2019/10/21/20924886/alexa-google-home-security-vulnerability-srlabs-phishing-eavesdropping).
|
||||
|
||||
|
||||
For that reason it is recommended not to have these devices in your house.
|
||||
The following are open source AI voice assistants, that aim to provide a
|
||||
human voice interface while also protecting your privacy and security
|
||||
|
|
@ -4375,7 +4456,7 @@ categories:
|
|||
[LinTO](https://linto.ai), [Jovo](https://www.jovo.tech) and [Snips](https://snips.ai)
|
||||
are private-by-design voice assistant frameworks that can be built on by developers,
|
||||
or used by enterprises.
|
||||
|
||||
|
||||
[Jasper](https://jasperproject.github.io),
|
||||
[Stephanie](https://github.com/SlapBot/stephanie-va) and
|
||||
[Hey Athena](https://github.com/rcbyron/hey-athena-client) are Python-based voice assistant, but neither is under active development anymore.
|
||||
|
|
@ -4392,7 +4473,7 @@ categories:
|
|||
description: |
|
||||
An open source privacy-respecting Home Assistant, compatible with a wide range of
|
||||
devices including Raspberry Pi, desktop computers, or NAS systems.
|
||||
Actively developed, with good french community and various integrations
|
||||
Actively developed, with good french community and various integrations
|
||||
(Zigbee, Philips, Camera, Tuya, MQTT, Telegram, ...).
|
||||
url: https://gladysassistant.com/
|
||||
github: gladysassistant/gladys
|
||||
|
|
@ -4435,7 +4516,7 @@ categories:
|
|||
notableMentions: |
|
||||
Other privacy-focused cryptocurrencies include:
|
||||
[PIVX](https://pivx.org),
|
||||
[Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/).
|
||||
[Verge](https://vergecurrency.com), and [Piratechain](https://pirate.black/).
|
||||
wordOfWarning: |
|
||||
Not all cryptocurrencies are anonymous, and without using a privacy-focused coin,
|
||||
a record of your transaction will live on a publicly available distributed ledger, forever.
|
||||
|
|
@ -4572,7 +4653,7 @@ categories:
|
|||
|
||||
[BitMex](https://www.bitmex.com/) has more advanced trading features,
|
||||
but ID verification is required for higher value trades involving Fiat currency.
|
||||
|
||||
|
||||
For buying and selling alt-coins, [Binance](https://www.binance.com/en/register?ref=X2BHKID1) has a wide range of currencies,
|
||||
~and ID verification is not needed for small-value trades~ but ID verification is required in most countries.
|
||||
|
||||
|
|
@ -4696,9 +4777,8 @@ categories:
|
|||
[Smart Wallet](https://apps.apple.com/app/smart-wallet/id1378013954) (iOS),
|
||||
[My-Budget](https://rezach.github.io/my-budget) (Desktop),
|
||||
[MoneyManager EX](https://www.moneymanagerex.org),
|
||||
[Skrooge](https://skrooge.org),
|
||||
[kMyMoney](https://kmymoney.org) and
|
||||
[Budget Zen](https://budgetzen.net) (a simple E2E encrypted budget manager)
|
||||
[Skrooge](https://skrooge.org), and
|
||||
[kMyMoney](https://kmymoney.org).
|
||||
|
||||
|
||||
- name: Social
|
||||
|
|
@ -4709,7 +4789,7 @@ categories:
|
|||
Over the past decade, social networks have revolutionized the way we communicate
|
||||
and bought the world closer together - but it came at the
|
||||
[cost of our privacy](https://en.wikipedia.org/wiki/Privacy_concerns_with_social_networking_services).
|
||||
|
||||
|
||||
Social networks are built on the principle of sharing - but you, the user
|
||||
should be able to choose with whom you share what, and that is what the
|
||||
following sites aim to do.
|
||||
|
|
@ -4740,12 +4820,12 @@ categories:
|
|||
|
||||
- name: nostr
|
||||
description: |
|
||||
nostr stands for Notes and other stuff transmitted by relays.
|
||||
It is an open protocol, not merely a platform.
|
||||
This distinction enables truly censorship-resistant and global value-for-value publishing on the web.
|
||||
With the power to replace data-greedy applications like Twitter and Instagram,
|
||||
nostr offers a promising alternative for users seeking a more private and secure online experience
|
||||
without algorithmic manipulations. ".... I feel like I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr.
|
||||
nostr stands for Notes and other stuff transmitted by relays.
|
||||
It is an open protocol, not merely a platform.
|
||||
This distinction enables truly censorship-resistant and global value-for-value publishing on the web.
|
||||
With the power to replace data-greedy applications like Twitter and Instagram,
|
||||
nostr offers a promising alternative for users seeking a more private and secure online experience
|
||||
without algorithmic manipulations. ".... I feel like I’m looking at the future." that is what [Snowden](https://x.com/Snowden/status/1617623779626352640) wrote about nostr.
|
||||
url: https://github.com/nostr-protocol/nostr
|
||||
github: https://github.com/nostr-protocol
|
||||
|
||||
|
|
@ -4864,7 +4944,7 @@ categories:
|
|||
then [Listed.to](https://listed.to) is a public blogging platform with
|
||||
strong privacy features.
|
||||
It lets you publish posts directly through the Standard Notes app or web interface.
|
||||
|
||||
|
||||
Other minimalistic platforms include [Notepin.co](https://notepin.co) and [Pen.io](http://pen.io).
|
||||
|
||||
Want to write a simple text post and promote it yourself?
|
||||
|
|
@ -4946,7 +5026,7 @@ categories:
|
|||
url: https://newpipe.schabi.org
|
||||
description: An open source, privacy-respecting YouTube client for Android.
|
||||
# tosdrId: 2568
|
||||
- name: FreeTube
|
||||
- name: FreeTube
|
||||
url: https://freetubeapp.io
|
||||
description: |
|
||||
An open source YouTube client for Windows, MacOS and Linux, providing
|
||||
|
|
@ -4955,7 +5035,10 @@ categories:
|
|||
# tosdrId: 3996
|
||||
wordOfWarning: |
|
||||
When proxies are involved - only use reputable services, and **never** enter any personal information
|
||||
|
||||
furtherInfo: |
|
||||
*[alternative-front-ends](https://github.com/mendel5/alternative-front-ends) provides information on
|
||||
dozens of alternative front-end options for popular platforms, such as YouTube, Twitter, Reddit,
|
||||
TikTok, and more.*
|
||||
|
||||
- name: Media
|
||||
sections:
|
||||
|
|
@ -4985,7 +5068,17 @@ categories:
|
|||
services: []
|
||||
- name: File Converters
|
||||
alternativeTo: ['format factory', 'handbrake', 'freemake video converter', 'any video converter', 'online-convert.com']
|
||||
services: []
|
||||
services:
|
||||
- name: FFmpeg
|
||||
url: https://ffmpeg.org/
|
||||
icon: https://ffmpeg.org/favicon.ico
|
||||
openSource: true
|
||||
github: FFmpeg/FFmpeg
|
||||
description: |
|
||||
A complete, cross-platform solution to record, convert, and stream audio and
|
||||
video. It's the industry standard multimedia framework, handling a vast range
|
||||
of formats. As a command-line tool, it guarantees that all processing is done
|
||||
locally on your machine.
|
||||
|
||||
- name: Creativity
|
||||
sections:
|
||||
|
|
@ -5076,7 +5169,7 @@ categories:
|
|||
|
||||
- name: Video Editors
|
||||
alternativeTo: ['adobe premiere pro', 'final cut pro', 'davinci resolve', 'imovie', 'sony vegas pro']
|
||||
services:
|
||||
services:
|
||||
- name: Shotcut
|
||||
followWith: Windows, Mac OS, Linux
|
||||
description: |
|
||||
|
|
@ -5141,7 +5234,7 @@ categories:
|
|||
github: NatronGitHub/Natron
|
||||
icon: https://natrongithub.github.io/img/Natron_icon.svg
|
||||
openSource: true
|
||||
|
||||
|
||||
- name: Audio Editors & Recorders
|
||||
alternativeTo: ['adobe audition', 'garageband', 'fl studio', 'ableton live']
|
||||
services:
|
||||
|
|
@ -5152,16 +5245,16 @@ categories:
|
|||
great free alternative to Adobe Audition.
|
||||
Features recording from real and virtual devices,
|
||||
import/export to a wide range of formats, high-quality processing
|
||||
advanced multi-track editing, noise reduction, pitch correction,
|
||||
advanced multi-track editing, noise reduction, pitch correction,
|
||||
audio restoration and much more.
|
||||
It's easily extendable via community plugins, and
|
||||
It's easily extendable via community plugins, and
|
||||
also supports cusotm macros and many scripting options
|
||||
url: https://www.audacityteam.org
|
||||
github: audacity/audacity
|
||||
icon: https://www.audacityteam.org/_astro/Audacity_Logo.63b57726.svg
|
||||
openSource: true
|
||||
tosdrId: 4516
|
||||
|
||||
|
||||
- name: Casting & Streaming
|
||||
alternativeTo: ['xsplit', 'streamlabs obs', 'twitch studio', 'wirecast']
|
||||
services:
|
||||
|
|
@ -5178,11 +5271,11 @@ categories:
|
|||
icon: https://obsproject.com/assets/images/new_icon_small-r.png
|
||||
openSource: true
|
||||
tosdrId: 4227
|
||||
|
||||
|
||||
- name: Screenshot Tools
|
||||
alternativeTo: ['snagit', 'greenshot', 'lightshot', 'gyazo', 'sharex']
|
||||
services: []
|
||||
|
||||
|
||||
- name: 3D Graphics
|
||||
alternativeTo: ['blender', 'autodesk maya', 'cinema 4d', '3ds max', 'sketchup']
|
||||
services:
|
||||
|
|
@ -5208,7 +5301,7 @@ categories:
|
|||
url: https://wings3d.com
|
||||
github: dgud/wings
|
||||
icon: https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Wings3d.png/120px-Wings3d.png
|
||||
|
||||
|
||||
- name: Animation
|
||||
alternativeTo: ['adobe after effects', 'animate cc', 'toon boom harmony', 'moho (anime studio)', 'pencil2d']
|
||||
services:
|
||||
|
|
|
|||
1
lib/.gitignore
vendored
Normal file
1
lib/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
__pycache__
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
"""
|
||||
Reads app list from awesome-privacy.yml,
|
||||
formats into markdown, and inserts into README.md
|
||||
formats into markdown, and inserts into README.md
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import yaml
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
# Configure Logging
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
|
|
@ -44,14 +44,32 @@ def tosElement(tosdrId):
|
|||
return ""
|
||||
return f"[](https://tosdr.org/en/service/{tosdrId})"
|
||||
|
||||
def statsElement(isOpenSource, isSecurityAudited, isAcceptsCrypto):
|
||||
def statsElement(app, categoryName, sectionName):
|
||||
statsStr = ""
|
||||
if isOpenSource == True:
|
||||
statsStr += "📦 Open Source "
|
||||
if isSecurityAudited == True:
|
||||
statsStr += "🛡️ Security Audited "
|
||||
if isAcceptsCrypto == True:
|
||||
statsStr += "💰 Accepts Anonymous Payment "
|
||||
if app.get('openSource') == True:
|
||||
github = app.get('github')
|
||||
if github:
|
||||
link = f"https://github.com/{github}"
|
||||
elif app.get('url'):
|
||||
link = app.get('url')
|
||||
else:
|
||||
link = f"https://awesome-privacy.xyz/{slugify(categoryName)}/{slugify(sectionName)}/{slugify(app.get('name'))}"
|
||||
statsStr += (
|
||||
f"[]({link}) "
|
||||
)
|
||||
if app.get('securityAudited') == True:
|
||||
statsStr += (
|
||||
" "
|
||||
)
|
||||
if app.get('acceptsCrypto') == True:
|
||||
statsStr += (
|
||||
" "
|
||||
)
|
||||
return statsStr
|
||||
|
||||
def slugify(title):
|
||||
|
|
@ -63,6 +81,11 @@ def slugify(title):
|
|||
title = title.replace('?', '')
|
||||
return title
|
||||
|
||||
def shieldsEncode(text):
|
||||
if not text: return ''
|
||||
text = text.strip().replace('-', '--').replace('_', '__').replace(' ', '_')
|
||||
return quote(text, safe='_-.')
|
||||
|
||||
def awesomePrivacyReport(categoryName, sectionName, serviceName):
|
||||
if not serviceName:
|
||||
return ""
|
||||
|
|
@ -72,12 +95,74 @@ def awesomePrivacyReport(categoryName, sectionName, serviceName):
|
|||
f"(https://awesome-privacy.xyz/{slugify(categoryName)}/{slugify(sectionName)}/{slugify(serviceName)})"
|
||||
)
|
||||
|
||||
def makeStatsCard():
|
||||
return (
|
||||
f"\t- <details><summary>Stats</summary>\n\n"
|
||||
f""
|
||||
f"\n\n</details>"
|
||||
)
|
||||
def playStoreBadge(name, androidApp):
|
||||
if not androidApp: return ""
|
||||
encoded = shieldsEncode(name)
|
||||
return (
|
||||
f"[]"
|
||||
f"(https://play.google.com/store/apps/details?id={androidApp}) "
|
||||
)
|
||||
|
||||
def appStoreBadge(name, iosApp):
|
||||
if not iosApp: return ""
|
||||
encoded = shieldsEncode(name)
|
||||
return (
|
||||
f"[]"
|
||||
f"({iosApp}) "
|
||||
)
|
||||
|
||||
def redditBadge(subreddit):
|
||||
if not subreddit or not subreddit.strip(): return ""
|
||||
sub = subreddit.strip()
|
||||
return (
|
||||
f"[]"
|
||||
f"(https://reddit.com/r/{sub}) "
|
||||
)
|
||||
|
||||
def discordBadge(name, discordInvite):
|
||||
if not discordInvite or not discordInvite.strip(): return ""
|
||||
invite = discordInvite.strip()
|
||||
encoded = shieldsEncode(name)
|
||||
link = invite if invite.startswith('https://') else f"https://discord.gg/{invite}"
|
||||
return (
|
||||
f"[]"
|
||||
f"({link}) "
|
||||
)
|
||||
|
||||
_MD_PATTERNS = [
|
||||
re.compile(r'\[([^\]]*)\]\([^)]*\)'), # [text](url) — group 1 = visible text
|
||||
re.compile(r'\*\*(.+?)\*\*'), # **bold**
|
||||
re.compile(r'`([^`]+)`'), # `code`
|
||||
re.compile(r'(?<!\*)\*([^*]+)\*(?!\*)'), # *italic*
|
||||
]
|
||||
|
||||
def truncateMarkdown(text, maxLen=200):
|
||||
"""Returns (truncated_text, was_truncated) preserving markdown constructs."""
|
||||
if len(text) <= maxLen:
|
||||
return text, False
|
||||
|
||||
result = []
|
||||
visible = 0
|
||||
i = 0
|
||||
|
||||
while i < len(text) and visible < maxLen:
|
||||
for pattern in _MD_PATTERNS:
|
||||
m = pattern.match(text, i)
|
||||
if m:
|
||||
result.append(m.group(0))
|
||||
visible += len(m.group(1))
|
||||
i = m.end()
|
||||
break
|
||||
else:
|
||||
result.append(text[i])
|
||||
visible += 1
|
||||
i += 1
|
||||
|
||||
return ''.join(result).rstrip(), True
|
||||
|
||||
def makeHref(text):
|
||||
if not text: return "#"
|
||||
|
|
@ -116,21 +201,32 @@ def makeAwesomePrivacy():
|
|||
)
|
||||
# For each service, list it's name, icon, url, and description
|
||||
for app in section.get('services') or []:
|
||||
description, was_truncated = truncateMarkdown(app.get('description', ''))
|
||||
ap_link = (
|
||||
f"https://awesome-privacy.xyz/"
|
||||
f"{slugify(category.get('name'))}/{slugify(section.get('name'))}/{slugify(app.get('name'))}"
|
||||
)
|
||||
ellipsis = f"[…]({ap_link} \"View full {app.get('name')} report\")" if was_truncated else ""
|
||||
markdown += (
|
||||
f"- **[{iconElement(app.get('url'), app.get('icon'))} {app.get('name')}]"
|
||||
f"({app.get('url')})** - {app.get('description')}"
|
||||
f"[…](https://awesome-privacy.xyz/"
|
||||
f"{slugify(category.get('name'))}/{slugify(section.get('name'))}/{slugify(app.get('name'))} \"View full {app.get('name')} report\") \n"
|
||||
+ ((
|
||||
f"\t- <details>\n\t\t<summary>Stats</summary>\n\n\t\t"
|
||||
f"{repoElement(app.get('github'))} "
|
||||
f"{tosElement(app.get('tosdrId'))} "
|
||||
f"{awesomePrivacyReport(category.get('name'), section.get('name'), app.get('name'))} \n"
|
||||
f"{statsElement(app.get('openSource'), app.get('securityAudited'), app.get('acceptsCrypto'))}˙ \n"
|
||||
f"\n\t\t</details>\n"
|
||||
)
|
||||
if app.get('github') or app.get('tosdrId') else '')
|
||||
f"({app.get('url')})** - {description}{ellipsis} \n"
|
||||
)
|
||||
badges = ' '.join(filter(None, [
|
||||
repoElement(app.get('github')),
|
||||
tosElement(app.get('tosdrId')),
|
||||
awesomePrivacyReport(category.get('name'), section.get('name'), app.get('name')),
|
||||
statsElement(app, category.get('name'), section.get('name')).rstrip(),
|
||||
playStoreBadge(app.get('name'), app.get('androidApp')).rstrip(),
|
||||
appStoreBadge(app.get('name'), app.get('iosApp')).rstrip(),
|
||||
redditBadge(app.get('subreddit')).rstrip(),
|
||||
discordBadge(app.get('name'), app.get('discordInvite')).rstrip(),
|
||||
]))
|
||||
if badges:
|
||||
markdown += (
|
||||
f"\t- <details>\n\t\t<summary>Stats</summary>\n\n\t\t"
|
||||
f"{badges}ㅤ \n"
|
||||
f"\n\t\t</details>\n"
|
||||
)
|
||||
markdown += "\n"
|
||||
# If word of warning exists, append it
|
||||
if section.get('wordOfWarning'):
|
||||
|
|
@ -145,7 +241,7 @@ def makeAwesomePrivacy():
|
|||
markdown += f"> - [{mention.get('name')}]({mention.get('url')})" + (
|
||||
f" - {mention.get('description')}" if mention.get('description') else "\n"
|
||||
)
|
||||
else:
|
||||
else:
|
||||
notable_mentions = section.get('notableMentions').replace('\n', '\n> ')
|
||||
markdown += f"> {notable_mentions}"
|
||||
|
||||
|
|
@ -170,7 +266,7 @@ def update_content_between_markers(content, start_marker, end_marker, new_conten
|
|||
logger.info(f"Updating content between {start_marker} and {end_marker} markers...")
|
||||
start_index = content.find(start_marker)
|
||||
end_index = content.find(end_marker)
|
||||
|
||||
|
||||
if start_index != -1 and end_index != -1:
|
||||
before_section = content[:start_index + len(start_marker)]
|
||||
after_section = content[end_index:]
|
||||
|
|
|
|||
291
lib/checks/check-additions.py
Normal file
291
lib/checks/check-additions.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""Validates data quality for added/modified services using the diff JSON."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
|
||||
DIFF_PATH = "/tmp/pr-diff.json"
|
||||
FINDINGS_PATH = "/tmp/findings-data.json"
|
||||
|
||||
REQUIRED_FIELDS = ("name", "description", "url", "icon")
|
||||
|
||||
CONTRIBUTING = "https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
|
||||
|
||||
SCHEMA_MSG = (
|
||||
"Some of the schema checks have failed. Please check that your addition"
|
||||
" contains all the required fields, with acceptable values, nothing"
|
||||
" additional and that it is following valid YAML syntax"
|
||||
)
|
||||
MULTIPLE_MSG = "Please make just one addition per pull request"
|
||||
MISSING_TPL = (
|
||||
"Did you include all required fields? Looks like {fields} is missing or"
|
||||
f" invalid. Please see the [required fields]({CONTRIBUTING}#service-fields)"
|
||||
" for available fields."
|
||||
)
|
||||
POSITION_MSG = (
|
||||
"New entries must be added to the end of the section, unless otherwise requested"
|
||||
)
|
||||
OPENSOURCE_MSG = (
|
||||
"You indicated this app/service is not open source. This will likely make"
|
||||
" it ineligible for listing on Awesome Privacy in accordance with our"
|
||||
f" [Requirements]({CONTRIBUTING}#requirements)."
|
||||
" Please ensure that this is justified in your PR body."
|
||||
)
|
||||
DUPLICATE_NAME_MSG = (
|
||||
"A service named `{name}` already exists (in {location})."
|
||||
" If this is a different service, please clarify in your PR description"
|
||||
)
|
||||
DUPLICATE_URL_MSG = (
|
||||
"The URL `{url}` is already associated with `{existing}`."
|
||||
" Please check this isn't a duplicate submission"
|
||||
)
|
||||
DESC_LENGTH_MSG = (
|
||||
"Description length ({length} chars) is outside the recommended 50\u2013250"
|
||||
f" character range. Please see our [Contributing Guidelines]({CONTRIBUTING}#description)"
|
||||
)
|
||||
OPENSOURCE_GITHUB_MSG = (
|
||||
"You marked this service as open source but didn't include a `github` field."
|
||||
" Please add the repository link"
|
||||
)
|
||||
|
||||
|
||||
def load_json(path):
|
||||
"""Load JSON from a file, returning None on any error."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_yaml_data(path):
|
||||
"""Load YAML from a file, returning None on any error."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_section_services(head, category, section):
|
||||
"""Return the services list for a category/section pair, or None."""
|
||||
for cat in head.get("categories", []):
|
||||
if cat.get("name") == category:
|
||||
for sec in cat.get("sections", []):
|
||||
if sec.get("name") == section:
|
||||
return sec.get("services", [])
|
||||
return None
|
||||
|
||||
|
||||
def find_service_fields(head, category, section, service_name):
|
||||
"""Look up a service's fields in the head YAML."""
|
||||
services = find_section_services(head, category, section)
|
||||
if services:
|
||||
for svc in services:
|
||||
if svc.get("name") == service_name:
|
||||
return svc
|
||||
return None
|
||||
|
||||
|
||||
def check_required_fields(diff, head):
|
||||
"""Return a finding if any added/modified service is missing required fields."""
|
||||
missing = set()
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
fields = svc.get("fields", {})
|
||||
for f in REQUIRED_FIELDS:
|
||||
if fields.get(f) is None:
|
||||
missing.add(f)
|
||||
for svc in diff.get("services", {}).get("modified", []):
|
||||
if not head:
|
||||
continue
|
||||
changed = svc.get("changed_fields", [])
|
||||
fields = find_service_fields(
|
||||
head, svc["category"], svc["section"], svc["service"]
|
||||
)
|
||||
if fields:
|
||||
for f in REQUIRED_FIELDS:
|
||||
if f in changed and fields.get(f) is None:
|
||||
missing.add(f)
|
||||
if missing:
|
||||
names = ", ".join(f"`{f}`" for f in sorted(missing))
|
||||
return MISSING_TPL.format(fields=names)
|
||||
return None
|
||||
|
||||
|
||||
def check_position(diff, head):
|
||||
"""Return a finding if a newly added service is not at the end of its section."""
|
||||
if not head:
|
||||
return None
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
services = find_section_services(head, svc["category"], svc["section"])
|
||||
if services and services[-1].get("name") != svc["service"]:
|
||||
return POSITION_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_open_source(diff):
|
||||
"""Return a finding if an added service has openSource missing or not true."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
fields = svc.get("fields", {})
|
||||
if fields.get("openSource") is not True and not fields.get("github"):
|
||||
return OPENSOURCE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_single_entry(diff):
|
||||
"""Return a finding if the diff adds multiple new services or sections."""
|
||||
services = diff.get("services", {})
|
||||
added_count = len(services.get("added", []))
|
||||
if added_count > 1:
|
||||
return MULTIPLE_MSG
|
||||
if added_count == 0:
|
||||
added_sections = [s for s in diff.get("sections", [])
|
||||
if s.get("change_type") == "added_section"]
|
||||
if len(added_sections) > 1:
|
||||
return MULTIPLE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def _added_keys(diff):
|
||||
"""Build a set of (category, section, lowercase_name) for added services."""
|
||||
keys = set()
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
name = svc.get("fields", {}).get("name", "").lower().strip()
|
||||
keys.add((svc.get("category", ""), svc.get("section", ""), name))
|
||||
return keys
|
||||
|
||||
|
||||
def build_name_index(head, diff):
|
||||
"""Build {lowercase_name: "category > section"} from all services, excluding additions."""
|
||||
index = {}
|
||||
if not head:
|
||||
return index
|
||||
exclude = _added_keys(diff)
|
||||
for cat in head.get("categories", []):
|
||||
cn = cat.get("name", "")
|
||||
for sec in cat.get("sections", []):
|
||||
sn = sec.get("name", "")
|
||||
for svc in sec.get("services", []):
|
||||
name = svc.get("name", "").lower().strip()
|
||||
if name and (cn, sn, name) not in exclude:
|
||||
index[name] = f"{cn} > {sn}"
|
||||
return index
|
||||
|
||||
|
||||
def build_url_index(head, diff):
|
||||
"""Build {url: service_name} from all services, excluding additions."""
|
||||
index = {}
|
||||
if not head:
|
||||
return index
|
||||
exclude = _added_keys(diff)
|
||||
for cat in head.get("categories", []):
|
||||
cn = cat.get("name", "")
|
||||
for sec in cat.get("sections", []):
|
||||
sn = sec.get("name", "")
|
||||
for svc in sec.get("services", []):
|
||||
name = svc.get("name", "").lower().strip()
|
||||
if (cn, sn, name) in exclude:
|
||||
continue
|
||||
url = svc.get("url", "")
|
||||
if url:
|
||||
index[url] = svc.get("name", "")
|
||||
return index
|
||||
|
||||
|
||||
def check_duplicate_name(diff, name_index):
|
||||
"""Return a finding if an added service name already exists in the YAML."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
name = svc.get("fields", {}).get("name", "").lower().strip()
|
||||
if name and name in name_index:
|
||||
return DUPLICATE_NAME_MSG.format(
|
||||
name=svc["fields"]["name"], location=name_index[name],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check_duplicate_url(diff, url_index):
|
||||
"""Return a finding if an added service URL already exists in the YAML."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
url = svc.get("fields", {}).get("url", "")
|
||||
if url and url in url_index:
|
||||
return DUPLICATE_URL_MSG.format(url=url, existing=url_index[url])
|
||||
return None
|
||||
|
||||
|
||||
def check_description_length(diff):
|
||||
"""Return a finding if an added service description is outside 50-250 chars."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
desc = svc.get("fields", {}).get("description", "")
|
||||
length = len(desc)
|
||||
if length < 50 or length > 280:
|
||||
return DESC_LENGTH_MSG.format(length=length)
|
||||
return None
|
||||
|
||||
|
||||
def check_opensource_github(diff):
|
||||
"""Return a finding if an added service is open source but has no github field."""
|
||||
for svc in diff.get("services", {}).get("added", []):
|
||||
fields = svc.get("fields", {})
|
||||
if fields.get("openSource") is True and not fields.get("github"):
|
||||
return OPENSOURCE_GITHUB_MSG
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
findings = []
|
||||
try:
|
||||
if os.environ.get("SCHEMA_OUTCOME") == "failure":
|
||||
findings.append(SCHEMA_MSG)
|
||||
|
||||
diff = load_json(DIFF_PATH)
|
||||
head = load_yaml_data(DATA_PATH)
|
||||
|
||||
if diff:
|
||||
finding = check_single_entry(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_required_fields(diff, head)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_position(diff, head)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_open_source(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
name_index = build_name_index(head, diff)
|
||||
url_index = build_url_index(head, diff)
|
||||
|
||||
finding = check_duplicate_name(diff, name_index)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_duplicate_url(diff, url_index)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_description_length(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_opensource_github(diff)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
172
lib/checks/check-pr-meta.py
Normal file
172
lib/checks/check-pr-meta.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""Checks PR metadata: title format, draft status, template completeness, and checkboxes."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
FINDINGS_PATH = "/tmp/findings-compliance.json"
|
||||
|
||||
BAD_TITLES = {"update readme.md", "update awesome-privacy.yml"}
|
||||
|
||||
TITLE_MSG = (
|
||||
"The pull request title does not follow the format defined in our guidelines."
|
||||
" Please rename it to `[Add/Remove/Update] [software name] in [software section]`"
|
||||
)
|
||||
DRAFT_MSG = (
|
||||
"Please avoid opening WIP pull requests."
|
||||
" Your PR should be 100% ready and complete before submitting"
|
||||
)
|
||||
TEMPLATE_MSG = (
|
||||
"Please fill in pull request template in full."
|
||||
" You can find a copy of this"
|
||||
" [here](https://github.com/Lissy93/awesome-privacy/blob/main/.github/PULL_REQUEST_TEMPLATE.md)"
|
||||
)
|
||||
CHECKBOX_MSG = (
|
||||
"Ensure you have completed the checklist (put a tick the checkboxes with `[x]`),"
|
||||
" to confirm that you've read the contributing guidelines, checked your submission,"
|
||||
" indicated your affiliation and agree to follow our CoC"
|
||||
)
|
||||
README_MSG = (
|
||||
"Do not edit the README directly. This file is auto-generated from the"
|
||||
" content in `awesome-privacy.yml`, and so your changes will be overridden!"
|
||||
" Instead, only modify the YAML file, and be sure to follow our Contributing Guidelines."
|
||||
)
|
||||
BOT_MSG = (
|
||||
"Submissions are only accepted from humans."
|
||||
" This PR appears to have been authored by a bot or AI assistant."
|
||||
)
|
||||
|
||||
_BOT_AUTHOR_RE = re.compile(
|
||||
r"(?:noreply@anthropic\.com|devin-ai-integration|copilot-swe-agent|noreply@cursor\.com)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_section(body, header):
|
||||
"""Extract content between a ### header and the next delimiter."""
|
||||
pattern = rf"###\s*{re.escape(header)}\s*\n(.*?)(?=\n---|\n###|\Z)"
|
||||
match = re.search(pattern, body, re.DOTALL)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def strip_html_comments(text):
|
||||
"""Remove HTML comments from text."""
|
||||
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL).strip()
|
||||
|
||||
|
||||
def check_title(title):
|
||||
"""Return a finding if the PR title matches a known-bad pattern."""
|
||||
if title and title.strip().lower() in BAD_TITLES:
|
||||
return TITLE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_draft(draft_str):
|
||||
"""Return a finding if the PR is in draft state."""
|
||||
if str(draft_str).lower() == "true":
|
||||
return DRAFT_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_template(body):
|
||||
"""Return a finding if required template sections are missing or empty."""
|
||||
for header in ("Type", "Changes", "Checklist"):
|
||||
content = extract_section(body, header)
|
||||
if content is None or not strip_html_comments(content):
|
||||
return TEMPLATE_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_checkboxes(body):
|
||||
"""Return a finding if any checklist checkboxes are unchecked."""
|
||||
section = extract_section(body, "Checklist")
|
||||
if section is None:
|
||||
return None
|
||||
checked = re.findall(r"- \[x\]", section, re.IGNORECASE)
|
||||
unchecked = re.findall(r"- \[ \]", section)
|
||||
if not checked and not unchecked:
|
||||
return None
|
||||
if unchecked:
|
||||
return CHECKBOX_MSG
|
||||
return None
|
||||
|
||||
|
||||
def check_bot_coauthors(base_ref):
|
||||
"""Return a finding if any commit in the PR has a bot author or co-author."""
|
||||
if not base_ref:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", f"{base_ref}..HEAD", "--format=%aN <%aE>%n%B"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
if _BOT_AUTHOR_RE.search(result.stdout):
|
||||
return BOT_MSG
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_readme(readme_failed):
|
||||
"""Return a finding if the README check reported a failure."""
|
||||
if readme_failed == "true":
|
||||
return README_MSG
|
||||
return None
|
||||
|
||||
|
||||
def write_findings(findings):
|
||||
"""Write the findings list to the output JSON file."""
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
|
||||
|
||||
def main():
|
||||
findings = []
|
||||
critical = False
|
||||
try:
|
||||
title = os.environ.get("PR_TITLE", "")
|
||||
body = os.environ.get("PR_BODY", "")
|
||||
draft = os.environ.get("PR_DRAFT", "false")
|
||||
readme_failed = os.environ.get("README_FAILED", "false")
|
||||
base_ref = os.environ.get("BASE_REF", "")
|
||||
|
||||
finding = check_bot_coauthors(base_ref)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_title(title)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_draft(draft)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
if not body or not body.strip():
|
||||
findings.append(TEMPLATE_MSG)
|
||||
critical = True
|
||||
else:
|
||||
finding = check_template(body)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
critical = True
|
||||
finding = check_checkboxes(body)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
finding = check_readme(readme_failed)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
write_findings(findings)
|
||||
sys.exit(1 if critical else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
400
lib/checks/check-project.py
Normal file
400
lib/checks/check-project.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
"""Checks project health: URL reachability, GitHub repo stars, activity, and author match."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
|
||||
DIFF_PATH = "/tmp/pr-diff.json"
|
||||
FINDINGS_PATH = "/tmp/findings-project.json"
|
||||
|
||||
TIMEOUT = 10
|
||||
USER_AGENT = "awesome-privacy-ci/1.0"
|
||||
MIN_STARS = 100
|
||||
INACTIVE_DAYS = 90
|
||||
MIN_AGE_DAYS = 120
|
||||
AI_COMMIT_RATIO = 0.2
|
||||
AI_BOT_AUTHORS = [
|
||||
"noreply@anthropic.com",
|
||||
"devin-ai-integration[bot]",
|
||||
"copilot-swe-agent.github.com",
|
||||
"noreply@cursor.com",
|
||||
]
|
||||
SPAM_PR_THRESHOLD = 5
|
||||
|
||||
LINK_MSG = (
|
||||
"Our automated checks were unable to verify the link(s) you included"
|
||||
" were reachable, so please double check this yourself"
|
||||
)
|
||||
AUTHOR_MSG = (
|
||||
"Looks like you are the author of this package. Please ensure that you"
|
||||
" have clearly disclosed this in your PR body for transparency"
|
||||
)
|
||||
STARS_MSG = (
|
||||
"It looks like your submission is quite a small project without a lot of users yet."
|
||||
" In some circumstances we may ask you to resubmit this once the project"
|
||||
" is more mature and has a proven track record of good practices and maintenance."
|
||||
)
|
||||
ACTIVITY_MSG = (
|
||||
"Please confirm that the project you are adding is actively maintained,"
|
||||
" as it looks to not have had any recent updates in the past 3 months."
|
||||
)
|
||||
MATURITY_MSG = (
|
||||
"This project appears to be quite new (created less than 4 months ago)."
|
||||
" Repositories should have a proven track record before listing."
|
||||
)
|
||||
AI_CODE_MSG = (
|
||||
"This project appears to contain AI-generated code."
|
||||
" Additional care will be needed when reviewing the submission."
|
||||
)
|
||||
FORK_MSG = (
|
||||
"The GitHub link in this listing is a fork."
|
||||
" Please confirm it's the correct (and actively maintained) repository"
|
||||
)
|
||||
LICENSE_MSG = (
|
||||
"There doesn't appear to be a license included in the project's GitHub repo"
|
||||
)
|
||||
ARCHIVED_MSG = (
|
||||
"The GitHub project linked has been archived."
|
||||
" Additions must be actively maintained."
|
||||
)
|
||||
SECURITY_MSG = (
|
||||
"This project has open security vulnerabilities (critical or high severity)"
|
||||
" flagged by GitHub Dependabot. Please verify these have been addressed"
|
||||
)
|
||||
SPAM_MSG = (
|
||||
"This user has opened up a large number of PRs to other awesome-* repos"
|
||||
" in the past 24 hours, and appears to be spamming"
|
||||
)
|
||||
|
||||
|
||||
def load_diff(path):
|
||||
"""Load the diff JSON, returning None on any error."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def check_url(url):
|
||||
"""Return True if the URL is reachable, True on any error (no false positives)."""
|
||||
try:
|
||||
resp = requests.head(
|
||||
url, timeout=TIMEOUT, allow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
resp = requests.get(
|
||||
url, timeout=TIMEOUT, allow_redirects=True,
|
||||
headers={"User-Agent": USER_AGENT}, stream=True,
|
||||
)
|
||||
resp.close()
|
||||
return resp.status_code < 400
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def parse_github_field(value):
|
||||
"""Parse a github field into (owner, repo), or (None, None) on failure."""
|
||||
if not value:
|
||||
return None, None
|
||||
if value.startswith("https://github.com/"):
|
||||
parts = value.removeprefix("https://github.com/").strip("/").split("/")
|
||||
if len(parts) >= 2:
|
||||
return parts[0], parts[1]
|
||||
return None, None
|
||||
if "/" in value:
|
||||
parts = value.split("/")
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return None, None
|
||||
|
||||
|
||||
def fetch_repo(owner, repo, token):
|
||||
"""Fetch GitHub repo metadata, returning None on any error."""
|
||||
try:
|
||||
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
resp = requests.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers=headers, timeout=TIMEOUT,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def load_yaml_data():
|
||||
"""Load the head YAML, returning None on any error."""
|
||||
try:
|
||||
with open(DATA_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def find_service_in_head(head, category, section, service_name):
|
||||
"""Look up a service in the head YAML by path."""
|
||||
if not head:
|
||||
return None
|
||||
for cat in head.get("categories", []):
|
||||
if cat.get("name") == category:
|
||||
for sec in cat.get("sections", []):
|
||||
if sec.get("name") == section:
|
||||
for svc in sec.get("services", []):
|
||||
if svc.get("name") == service_name:
|
||||
return svc
|
||||
return None
|
||||
|
||||
|
||||
def get_services(diff, key):
|
||||
"""Safely extract a service list from the diff."""
|
||||
return diff.get("services", {}).get(key, [])
|
||||
|
||||
|
||||
def check_links(diff, head):
|
||||
"""Return LINK_MSG if any service URL or icon URL is unreachable."""
|
||||
for svc in get_services(diff, "added"):
|
||||
fields = svc.get("fields", {})
|
||||
url = fields.get("url")
|
||||
if url and not check_url(url):
|
||||
return LINK_MSG
|
||||
icon = fields.get("icon")
|
||||
if icon and not check_url(icon):
|
||||
return LINK_MSG
|
||||
for svc in get_services(diff, "modified"):
|
||||
changed = svc.get("changed_fields", [])
|
||||
if "url" not in changed and "icon" not in changed:
|
||||
continue
|
||||
head_svc = find_service_in_head(
|
||||
head, svc["category"], svc["section"], svc["service"]
|
||||
)
|
||||
if head_svc:
|
||||
if "url" in changed:
|
||||
url = head_svc.get("url")
|
||||
if url and not check_url(url):
|
||||
return LINK_MSG
|
||||
if "icon" in changed:
|
||||
icon = head_svc.get("icon")
|
||||
if icon and not check_url(icon):
|
||||
return LINK_MSG
|
||||
return None
|
||||
|
||||
|
||||
def _commit_has_bot(commit, bot_set):
|
||||
"""Check if a commit was authored or co-authored by a known AI bot."""
|
||||
author = commit.get("commit", {}).get("author", {})
|
||||
email = (author.get("email") or "").lower()
|
||||
name = (author.get("name") or "").lower()
|
||||
if email in bot_set or name in bot_set:
|
||||
return True
|
||||
message = (commit.get("commit", {}).get("message") or "").lower()
|
||||
for line in message.splitlines():
|
||||
if line.strip().startswith("co-authored-by:"):
|
||||
if any(bot in line for bot in bot_set):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_ai_commits(owner, repo, token):
|
||||
"""Return AI_CODE_MSG if recent commits contain significant AI bot activity."""
|
||||
try:
|
||||
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
resp = requests.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/commits",
|
||||
headers=headers, timeout=TIMEOUT, params={"per_page": 100},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
commits = resp.json()
|
||||
if not commits:
|
||||
return None
|
||||
bot_set = {a.lower() for a in AI_BOT_AUTHORS}
|
||||
count = sum(1 for c in commits if _commit_has_bot(c, bot_set))
|
||||
if count / len(commits) >= AI_COMMIT_RATIO:
|
||||
return AI_CODE_MSG
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_security_alerts(owner, repo, token):
|
||||
"""Return SECURITY_MSG if the repo has open critical/high Dependabot alerts."""
|
||||
try:
|
||||
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
resp = requests.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/dependabot/alerts",
|
||||
headers=headers, timeout=TIMEOUT,
|
||||
params={"state": "open", "severity": "critical,high", "per_page": 1},
|
||||
)
|
||||
if resp.status_code == 200 and resp.json():
|
||||
return SECURITY_MSG
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_spam_prs(pr_user, token):
|
||||
"""Return SPAM_MSG if the user has opened many PRs to other awesome-* repos recently."""
|
||||
if not pr_user or not token:
|
||||
return None
|
||||
try:
|
||||
since = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": USER_AGENT}
|
||||
headers["Authorization"] = f"token {token}"
|
||||
resp = requests.get(
|
||||
"https://api.github.com/search/issues",
|
||||
headers=headers, timeout=TIMEOUT,
|
||||
params={"q": f"type:pr author:{pr_user} created:>={since}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
items = resp.json().get("items", [])
|
||||
this_repo = os.environ.get("GITHUB_REPOSITORY", "Lissy93/awesome-privacy").lower()
|
||||
count = 0
|
||||
for item in items:
|
||||
repo_url = item.get("repository_url", "")
|
||||
# repository_url looks like https://api.github.com/repos/owner/repo-name
|
||||
repo_full = "/".join(repo_url.rstrip("/").split("/")[-2:]).lower()
|
||||
repo_name = repo_url.rstrip("/").split("/")[-1].lower()
|
||||
if repo_name.startswith("awesome-") and repo_full != this_repo:
|
||||
count += 1
|
||||
if count >= SPAM_PR_THRESHOLD:
|
||||
return SPAM_MSG
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
_DISCLOSURE_RE = re.compile(
|
||||
r"i am the author|i'm the author|my project|i created|i develop"
|
||||
r"|i maintain|i built|my own project|i made|author of|maintainer of",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _pr_discloses_authorship(pr_body):
|
||||
"""Return True if the PR body already discloses the submitter is the author."""
|
||||
return bool(pr_body and _DISCLOSURE_RE.search(pr_body))
|
||||
|
||||
|
||||
def check_repo_signals(diff, pr_user, token, pr_body=""):
|
||||
"""Check GitHub repo author match, stars, and activity for added services."""
|
||||
findings = []
|
||||
if not token:
|
||||
return findings
|
||||
cache = {}
|
||||
for svc in get_services(diff, "added"):
|
||||
gh = svc.get("fields", {}).get("github")
|
||||
owner, repo = parse_github_field(gh)
|
||||
if not owner:
|
||||
continue
|
||||
cache_key = f"{owner}/{repo}"
|
||||
if cache_key not in cache:
|
||||
cache[cache_key] = fetch_repo(owner, repo, token)
|
||||
data = cache[cache_key]
|
||||
if not data:
|
||||
continue
|
||||
|
||||
repo_owner = data.get("owner", {})
|
||||
if (
|
||||
pr_user
|
||||
and repo_owner.get("type") == "User"
|
||||
and repo_owner.get("login", "").lower() == pr_user.lower()
|
||||
and AUTHOR_MSG not in findings
|
||||
and not _pr_discloses_authorship(pr_body)
|
||||
):
|
||||
findings.append(AUTHOR_MSG)
|
||||
|
||||
stars = data.get("stargazers_count", 0)
|
||||
if stars < MIN_STARS and STARS_MSG not in findings:
|
||||
findings.append(STARS_MSG)
|
||||
|
||||
if data.get("fork") and FORK_MSG not in findings:
|
||||
findings.append(FORK_MSG)
|
||||
|
||||
if not data.get("license") and LICENSE_MSG not in findings:
|
||||
findings.append(LICENSE_MSG)
|
||||
|
||||
if data.get("archived") and ARCHIVED_MSG not in findings:
|
||||
findings.append(ARCHIVED_MSG)
|
||||
|
||||
pushed = data.get("pushed_at")
|
||||
if pushed and ACTIVITY_MSG not in findings:
|
||||
try:
|
||||
pushed_dt = datetime.fromisoformat(pushed.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
if (now - pushed_dt).days > INACTIVE_DAYS:
|
||||
findings.append(ACTIVITY_MSG)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
created = data.get("created_at")
|
||||
if created and MATURITY_MSG not in findings:
|
||||
try:
|
||||
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
if (now - created_dt).days < MIN_AGE_DAYS:
|
||||
findings.append(MATURITY_MSG)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if AI_CODE_MSG not in findings:
|
||||
finding = check_ai_commits(owner, repo, token)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
if SECURITY_MSG not in findings:
|
||||
finding = check_security_alerts(owner, repo, token)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def main():
|
||||
findings = []
|
||||
try:
|
||||
diff = load_diff(DIFF_PATH)
|
||||
if not diff:
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
sys.exit(0)
|
||||
|
||||
head = load_yaml_data()
|
||||
finding = check_links(diff, head)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
|
||||
pr_user = os.environ.get("PR_USER", "")
|
||||
pr_body = os.environ.get("PR_BODY", "")
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
findings.extend(check_repo_signals(diff, pr_user, token, pr_body))
|
||||
|
||||
finding = check_spam_prs(pr_user, token)
|
||||
if finding:
|
||||
findings.append(finding)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with open(FINDINGS_PATH, "w") as f:
|
||||
json.dump(findings, f)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
105
lib/checks/check-readme-edits.py
Normal file
105
lib/checks/check-readme-edits.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""
|
||||
Fails if the PR directly edits the auto-generated section of the README.
|
||||
The generated section is between <!-- awesome-privacy-start --> and <!-- awesome-privacy-end -->.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
README_PATH = ".github/README.md"
|
||||
README_ABS = os.path.join(PROJECT_ROOT, README_PATH)
|
||||
|
||||
# Exit codes
|
||||
EXIT_PASS = 0
|
||||
EXIT_FAIL = 1
|
||||
EXIT_RUNTIME_ERROR = 2
|
||||
|
||||
# ANSI color helpers
|
||||
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
|
||||
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
|
||||
|
||||
def get_changed_files(base_ref):
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{base_ref}..HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
return result.stdout.strip().splitlines()
|
||||
|
||||
|
||||
def get_marker_lines():
|
||||
"""Find the line numbers of the start/end markers in the README."""
|
||||
try:
|
||||
with open(README_ABS, "r") as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
return None, None
|
||||
|
||||
start_line = None
|
||||
end_line = None
|
||||
for i, line in enumerate(lines, start=1):
|
||||
if "<!-- awesome-privacy-start -->" in line:
|
||||
start_line = i
|
||||
if "<!-- awesome-privacy-end -->" in line:
|
||||
end_line = i
|
||||
|
||||
return start_line, end_line
|
||||
|
||||
|
||||
def get_changed_line_numbers(base_ref):
|
||||
"""Parse git diff hunk headers to find which lines were changed in the README."""
|
||||
result = subprocess.run(
|
||||
["git", "diff", "-U0", f"{base_ref}..HEAD", "--", README_PATH],
|
||||
capture_output=True, text=True, check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
changed_lines = []
|
||||
for line in result.stdout.splitlines():
|
||||
# Match hunk headers like @@ -10,5 +12,7 @@
|
||||
match = re.match(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
count = int(match.group(2)) if match.group(2) else 1
|
||||
for n in range(start, start + count):
|
||||
changed_lines.append(n)
|
||||
|
||||
return changed_lines
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Check for direct README edits to generated section")
|
||||
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Skip if README wasn't changed
|
||||
changed_files = get_changed_files(args.base_ref)
|
||||
if README_PATH not in changed_files:
|
||||
print(green("README not modified, skipping."))
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
# Find marker lines
|
||||
start_line, end_line = get_marker_lines()
|
||||
if start_line is None or end_line is None:
|
||||
print("Could not find generated-section markers in README, skipping check.")
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
# Check if any changed lines fall within the generated section
|
||||
changed_lines = get_changed_line_numbers(args.base_ref)
|
||||
for line_num in changed_lines:
|
||||
if start_line <= line_num <= end_line:
|
||||
print(red("Direct edits to the generated section of the README are not allowed."), file=sys.stderr)
|
||||
print(red("Edit awesome-privacy.yml instead and the README will be regenerated."), file=sys.stderr)
|
||||
sys.exit(EXIT_FAIL)
|
||||
|
||||
print(green("README changes are outside the generated section, OK."))
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
90
lib/checks/check-review-ready.py
Normal file
90
lib/checks/check-review-ready.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Decides whether a PR is ready for maintainer review.
|
||||
|
||||
Conditions: 2+ approvals from external contributors AND all CI checks passing.
|
||||
|
||||
Reads:
|
||||
pr-meta/reviews.json — array of {user, state} from GitHub API
|
||||
pr-meta/check-runs.json — array of {status, conclusion} from GitHub API
|
||||
pr-meta/already-notified.txt — "true" if notification comment already exists
|
||||
|
||||
Writes:
|
||||
pr-meta/action.txt — "notify" or "skip"
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
WORK_DIR = "pr-meta"
|
||||
MAINTAINER = "Lissy93"
|
||||
REQUIRED_APPROVALS = 2
|
||||
PASSING_CONCLUSIONS = {"success", "skipped", "neutral"}
|
||||
|
||||
|
||||
def read_json(filename):
|
||||
"""Load a JSON file from the work directory, or empty list on error."""
|
||||
try:
|
||||
with open(os.path.join(WORK_DIR, filename)) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def count_external_approvals(reviews):
|
||||
"""Count unique non-maintainer users who approved."""
|
||||
approvers = {
|
||||
r["user"]
|
||||
for r in reviews
|
||||
if r.get("state") == "APPROVED"
|
||||
and r.get("user", "").lower() != MAINTAINER.lower()
|
||||
}
|
||||
return len(approvers)
|
||||
|
||||
|
||||
def all_checks_passing(check_runs):
|
||||
"""Return True if every check run completed successfully."""
|
||||
if not check_runs:
|
||||
return False
|
||||
return all(
|
||||
cr.get("status") == "completed"
|
||||
and cr.get("conclusion") in PASSING_CONCLUSIONS
|
||||
for cr in check_runs
|
||||
)
|
||||
|
||||
|
||||
def already_notified():
|
||||
"""Return True if the notification comment already exists on the PR."""
|
||||
try:
|
||||
with open(os.path.join(WORK_DIR, "already-notified.txt")) as f:
|
||||
return f.read().strip().lower() == "true"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def write_action(action):
|
||||
os.makedirs(WORK_DIR, exist_ok=True)
|
||||
with open(os.path.join(WORK_DIR, "action.txt"), "w") as f:
|
||||
f.write(action)
|
||||
|
||||
|
||||
def main():
|
||||
if already_notified():
|
||||
write_action("skip")
|
||||
return
|
||||
|
||||
reviews = read_json("reviews.json")
|
||||
approvals = count_external_approvals(reviews)
|
||||
|
||||
if approvals < REQUIRED_APPROVALS:
|
||||
write_action("skip")
|
||||
return
|
||||
|
||||
check_runs = read_json("check-runs.json")
|
||||
if not all_checks_passing(check_runs):
|
||||
write_action("skip")
|
||||
return
|
||||
|
||||
write_action("notify")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
255
lib/checks/check-yaml-diff.py
Normal file
255
lib/checks/check-yaml-diff.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"""Analyzes the diff between base and head versions of awesome-privacy.yml.
|
||||
Enforces the single-entry rule and outputs a JSON diff to /tmp/pr-diff.json.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
|
||||
DIFF_OUTPUT_PATH = "/tmp/pr-diff.json"
|
||||
SUMMARY_OUTPUT_PATH = "/tmp/pr-diff-summary.md"
|
||||
|
||||
EXIT_PASS = 0
|
||||
EXIT_RULE_VIOLATION = 1
|
||||
EXIT_RUNTIME_ERROR = 2
|
||||
|
||||
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
|
||||
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
|
||||
|
||||
def load_base_yaml(base_ref):
|
||||
"""Load the YAML from the base ref using git show."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "show", f"{base_ref}:awesome-privacy.yml"],
|
||||
capture_output=True, text=True, check=True, cwd=PROJECT_ROOT,
|
||||
)
|
||||
return yaml.safe_load(result.stdout)
|
||||
except subprocess.CalledProcessError:
|
||||
print(yellow("awesome-privacy.yml not found in base ref, treating as empty"), file=sys.stderr)
|
||||
return {"categories": []}
|
||||
except yaml.YAMLError as e:
|
||||
print(red(f"Failed to parse base YAML: {e}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
|
||||
|
||||
def load_head_yaml():
|
||||
"""Load the YAML from the current working tree."""
|
||||
try:
|
||||
with open(DATA_PATH) as f:
|
||||
return yaml.safe_load(f)
|
||||
except (FileNotFoundError, yaml.YAMLError) as e:
|
||||
print(red(f"Failed to load head YAML: {e}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
|
||||
|
||||
def build_index(data, depth):
|
||||
"""Build a keyed index at the given depth (3=services, 2=sections, 1=categories)."""
|
||||
index = {}
|
||||
for cat in data.get("categories", []):
|
||||
cn = cat.get("name", "")
|
||||
if depth == 1:
|
||||
index[cn] = {k: v for k, v in cat.items() if k != "sections"}
|
||||
continue
|
||||
for sec in cat.get("sections", []):
|
||||
sn = sec.get("name", "")
|
||||
if depth == 2:
|
||||
index[(cn, sn)] = {k: v for k, v in sec.items() if k != "services"}
|
||||
continue
|
||||
for svc in sec.get("services", []):
|
||||
index[(cn, sn, svc.get("name", ""))] = svc
|
||||
return index
|
||||
|
||||
|
||||
def diff_index(base_idx, head_idx):
|
||||
"""Return (added_keys, removed_keys, modified_keys_with_changed_fields)."""
|
||||
base_keys, head_keys = set(base_idx), set(head_idx)
|
||||
added = sorted(head_keys - base_keys)
|
||||
removed = sorted(base_keys - head_keys)
|
||||
modified = []
|
||||
for key in sorted(base_keys & head_keys):
|
||||
if base_idx[key] != head_idx[key]:
|
||||
all_fields = set(base_idx[key]) | set(head_idx[key])
|
||||
changed = sorted(f for f in all_fields if base_idx[key].get(f) != head_idx[key].get(f))
|
||||
modified.append((key, changed))
|
||||
return added, removed, modified
|
||||
|
||||
|
||||
def write_github_output(name, value):
|
||||
"""Write a value to $GITHUB_OUTPUT."""
|
||||
output_file = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_file:
|
||||
with open(output_file, "a") as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def find_duplicate_names(data):
|
||||
"""Find duplicate service names within the same section."""
|
||||
duplicates = []
|
||||
for cat in data.get("categories", []):
|
||||
cn = cat.get("name", "")
|
||||
for sec in cat.get("sections", []):
|
||||
sn = sec.get("name", "")
|
||||
seen = {}
|
||||
for svc in sec.get("services", []):
|
||||
name = svc.get("name", "")
|
||||
if name in seen:
|
||||
duplicates.append((cn, sn, name))
|
||||
else:
|
||||
seen[name] = True
|
||||
return duplicates
|
||||
|
||||
|
||||
def fmt_path(key):
|
||||
"""Format a tuple key as a readable path."""
|
||||
return " → ".join(key) if isinstance(key, tuple) else key
|
||||
|
||||
|
||||
def format_diff_bullets(diff_result):
|
||||
"""Build bullet-point lines summarizing all changes. Returns list of strings or empty list."""
|
||||
bullets = []
|
||||
|
||||
for svc in diff_result["services"]["added"]:
|
||||
bullets.append(f"- Added **{svc['service']}** in {svc['category']} → {svc['section']}")
|
||||
for svc in diff_result["services"]["removed"]:
|
||||
bullets.append(f"- Removed **{svc['service']}** from {svc['category']} → {svc['section']}")
|
||||
for svc in diff_result["services"]["modified"]:
|
||||
fields = ", ".join(f"`{f}`" for f in svc["changed_fields"])
|
||||
bullets.append(f"- Modified {fields} in {svc['category']} → {svc['section']} → {svc['service']}")
|
||||
for change in diff_result["sections"]:
|
||||
ct = change["change_type"]
|
||||
path = f"{change['category']} → {change['section']}"
|
||||
if ct == "added_section":
|
||||
bullets.append(f"- Added section **{change['section']}** in {change['category']}")
|
||||
elif ct == "removed_section":
|
||||
bullets.append(f"- Removed section **{change['section']}** from {change['category']}")
|
||||
else:
|
||||
fields = ", ".join(f"`{f}`" for f in change.get("changed_fields", []))
|
||||
bullets.append(f"- Modified section metadata ({fields}) in {path}")
|
||||
for change in diff_result["categories"]:
|
||||
if change["change_type"] == "added_category":
|
||||
bullets.append(f"- Added category **{change['category']}**")
|
||||
else:
|
||||
bullets.append(f"- Removed category **{change['category']}**")
|
||||
for dup in diff_result.get("duplicates", []):
|
||||
bullets.append(
|
||||
f"- ⚠️ Duplicate service name **{dup['service']}** "
|
||||
f"in {dup['category']} → {dup['section']}"
|
||||
)
|
||||
|
||||
return bullets
|
||||
|
||||
|
||||
def write_diff_summary(diff_result):
|
||||
"""Write the bullet-point summary to a file for downstream consumers."""
|
||||
bullets = format_diff_bullets(diff_result)
|
||||
if bullets:
|
||||
with open(SUMMARY_OUTPUT_PATH, "w") as f:
|
||||
f.write("\n".join(bullets) + "\n")
|
||||
|
||||
|
||||
def write_step_summary(diff_result):
|
||||
"""Write a bullet-point Markdown summary to $GITHUB_STEP_SUMMARY."""
|
||||
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not summary_file:
|
||||
return
|
||||
|
||||
bullets = format_diff_bullets(diff_result)
|
||||
lines = ["## YAML Diff Analysis\n"]
|
||||
if bullets:
|
||||
lines.extend(bullets)
|
||||
else:
|
||||
lines.append("No changes detected in `awesome-privacy.yml`.")
|
||||
|
||||
with open(summary_file, "a") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-ref", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
base = load_base_yaml(args.base_ref)
|
||||
head = load_head_yaml()
|
||||
|
||||
svc_added, svc_removed, svc_modified = diff_index(
|
||||
build_index(base, 3), build_index(head, 3),
|
||||
)
|
||||
sec_added, sec_removed, sec_modified = diff_index(
|
||||
build_index(base, 2), build_index(head, 2),
|
||||
)
|
||||
cat_added, cat_removed, _ = diff_index(
|
||||
build_index(base, 1), build_index(head, 1),
|
||||
)
|
||||
|
||||
added = [{"category": k[0], "section": k[1], "service": k[2],
|
||||
"fields": build_index(head, 3)[k]} for k in svc_added]
|
||||
removed = [{"category": k[0], "section": k[1], "service": k[2]} for k in svc_removed]
|
||||
modified = [{"category": k[0], "section": k[1], "service": k[2],
|
||||
"changed_fields": cf} for k, cf in svc_modified]
|
||||
|
||||
sections = []
|
||||
for k in sec_added:
|
||||
sections.append({"category": k[0], "section": k[1], "change_type": "added_section"})
|
||||
for k in sec_removed:
|
||||
sections.append({"category": k[0], "section": k[1], "change_type": "removed_section"})
|
||||
for k, cf in sec_modified:
|
||||
sections.append({"category": k[0], "section": k[1],
|
||||
"change_type": "modified_section_metadata", "changed_fields": cf})
|
||||
|
||||
categories = []
|
||||
for k in cat_added:
|
||||
categories.append({"category": k, "change_type": "added_category"})
|
||||
for k in cat_removed:
|
||||
categories.append({"category": k, "change_type": "removed_category"})
|
||||
|
||||
duplicates = find_duplicate_names(head)
|
||||
dup_entries = [{"category": d[0], "section": d[1], "service": d[2]}
|
||||
for d in duplicates]
|
||||
|
||||
diff_result = {
|
||||
"services": {"added": added, "removed": removed, "modified": modified},
|
||||
"sections": sections,
|
||||
"categories": categories,
|
||||
"duplicates": dup_entries,
|
||||
}
|
||||
|
||||
with open(DIFF_OUTPUT_PATH, "w") as f:
|
||||
json.dump(diff_result, f, indent=2)
|
||||
|
||||
write_github_output("has_service_changes", str(bool(added or removed or modified)).lower())
|
||||
write_step_summary(diff_result)
|
||||
write_diff_summary(diff_result)
|
||||
|
||||
added_count = len(added)
|
||||
if added_count > 1:
|
||||
print(red(f"Single-entry rule violation: {added_count} service additions found."), file=sys.stderr)
|
||||
sys.exit(EXIT_RULE_VIOLATION)
|
||||
added_sections = [s for s in sections if s["change_type"] == "added_section"]
|
||||
if added_count == 0 and len(added_sections) > 1:
|
||||
print(red(f"Single-entry rule violation: {len(added_sections)} section additions found."), file=sys.stderr)
|
||||
sys.exit(EXIT_RULE_VIOLATION)
|
||||
|
||||
if duplicates:
|
||||
names = ", ".join(f"{d[2]} (in {d[0]} → {d[1]})" for d in duplicates)
|
||||
print(red(f"Duplicate service names found: {names}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RULE_VIOLATION)
|
||||
|
||||
total = len(added) + len(removed) + len(modified)
|
||||
print(green(f"Single-entry rule passed. {total} service "
|
||||
f"({added_count} added), {len(sections)} section, "
|
||||
f"{len(categories)} category change(s)."))
|
||||
sys.exit(EXIT_PASS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
lib/checks/detect-changes.py
Normal file
46
lib/checks/detect-changes.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""
|
||||
Detects which files changed between the PR base and HEAD.
|
||||
Sets GitHub Actions output: yaml_changed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
YAML_FILE = "awesome-privacy.yml"
|
||||
|
||||
|
||||
def write_github_output(name, value):
|
||||
output_file = os.environ.get("GITHUB_OUTPUT")
|
||||
if output_file:
|
||||
with open(output_file, "a") as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Detect changed files in a PR")
|
||||
parser.add_argument("--base-ref", required=True, help="Base git ref to diff against")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{args.base_ref}..HEAD"],
|
||||
capture_output=True, text=True, check=True,
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
changed_files = [f for f in result.stdout.strip().splitlines() if f]
|
||||
|
||||
print("Changed files:")
|
||||
for f in changed_files:
|
||||
print(f" {f}")
|
||||
|
||||
yaml_changed = YAML_FILE in changed_files
|
||||
write_github_output("yaml_changed", str(yaml_changed).lower())
|
||||
print(f"yaml_changed={yaml_changed}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
126
lib/checks/format-comment.py
Normal file
126
lib/checks/format-comment.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Aggregates findings from all check jobs into a formatted PR comment."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ARTIFACTS_DIR = "/tmp/artifacts"
|
||||
OUTPUT_DIR = "/tmp/pr-meta"
|
||||
|
||||
REPO_URL = "https://github.com/Lissy93/awesome-privacy"
|
||||
CONTRIBUTING = f"{REPO_URL}/blob/main/.github/CONTRIBUTING.md"
|
||||
DIFF_SUMMARY_PATH = os.path.join(ARTIFACTS_DIR, "pr-diff-summary.md")
|
||||
|
||||
|
||||
def load_findings(filename):
|
||||
"""Load a findings JSON array from the artifacts directory, or empty list on error."""
|
||||
try:
|
||||
with open(os.path.join(ARTIFACTS_DIR, filename)) as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def collect_findings():
|
||||
"""Gather all findings in display order: compliance, data, project."""
|
||||
all_findings = []
|
||||
all_findings.extend(load_findings("findings-compliance.json"))
|
||||
all_findings.extend(load_findings("findings-data.json"))
|
||||
all_findings.extend(load_findings("findings-project.json"))
|
||||
return all_findings
|
||||
|
||||
|
||||
def load_diff_summary():
|
||||
"""Load the pre-formatted diff summary, or None if unavailable."""
|
||||
try:
|
||||
with open(DIFF_SUMMARY_PATH) as f:
|
||||
content = f.read().strip()
|
||||
return content if content else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def format_comment(findings, user, changes_summary, run_id):
|
||||
"""Build the markdown comment."""
|
||||
parts = [
|
||||
f"<!-- pr-check-bot -->\nHello @{user}\n",
|
||||
f"Thank you for contributing to Awesome Privacy! We will review your "
|
||||
f"submission shortly. In the meantime, please ensure all changes are "
|
||||
f"correct and inline with our [Contributing Requirements]({CONTRIBUTING}).\n",
|
||||
]
|
||||
|
||||
if findings:
|
||||
bullet_list = "\n".join(f"- {f}" for f in findings)
|
||||
parts.append(
|
||||
f"Our automated checks detected some issues:\n\n{bullet_list}\n\n"
|
||||
f"> [!NOTE]\n"
|
||||
f"> I am a bot, and sometimes make mistakes in my suggestions. "
|
||||
f"But a human will review your submission shortly!"
|
||||
)
|
||||
else:
|
||||
parts.append("> ✅ All our automated checks have passed.")
|
||||
|
||||
if changes_summary:
|
||||
parts.append(
|
||||
f"<details><summary>Summary of Changes:</summary>\n\n"
|
||||
f"{changes_summary}\n</details>"
|
||||
)
|
||||
|
||||
if run_id:
|
||||
parts.append(
|
||||
f'<sup>For full details, please see workflow run '
|
||||
f'<a href="{REPO_URL}/actions/runs/{run_id}">{run_id}</a></sup>'
|
||||
)
|
||||
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def write_step_summary(findings):
|
||||
"""Write a summary to GITHUB_STEP_SUMMARY."""
|
||||
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
|
||||
if not summary_file:
|
||||
return
|
||||
lines = ["## PR Check Summary\n"]
|
||||
if findings:
|
||||
lines.append(f"⚠️ Found {len(findings)} issue(s):\n")
|
||||
for f in findings:
|
||||
lines.append(f"- {f}")
|
||||
else:
|
||||
lines.append("✅ All checks passed.\n")
|
||||
with open(summary_file, "a") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
user = os.environ.get("PR_USER", "contributor")
|
||||
pr_number = os.environ.get("PR_NUMBER", "")
|
||||
run_id = os.environ.get("RUN_ID", "")
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
if pr_number:
|
||||
with open(os.path.join(OUTPUT_DIR, "number.txt"), "w") as f:
|
||||
f.write(pr_number)
|
||||
if run_id:
|
||||
with open(os.path.join(OUTPUT_DIR, "run-id.txt"), "w") as f:
|
||||
f.write(run_id)
|
||||
|
||||
findings = collect_findings()
|
||||
with open(os.path.join(OUTPUT_DIR, "findings-count.txt"), "w") as f:
|
||||
f.write(str(len(findings)))
|
||||
changes_summary = load_diff_summary()
|
||||
write_step_summary(findings)
|
||||
|
||||
comment = format_comment(findings, user, changes_summary, run_id)
|
||||
with open(os.path.join(OUTPUT_DIR, "comment.md"), "w") as f:
|
||||
f.write(comment)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
169
lib/checks/prepare-comment.py
Normal file
169
lib/checks/prepare-comment.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Decides whether to create, update, or skip the PR bot comment.
|
||||
|
||||
Reads:
|
||||
pr-meta/comment.md — new comment from format-comment.py
|
||||
pr-meta/findings-count.txt — number of findings (from format-comment.py)
|
||||
pr-meta/existing-comment.md — current bot comment on the PR (from workflow)
|
||||
|
||||
Writes:
|
||||
pr-meta/action.txt — "create", "update", or "skip"
|
||||
pr-meta/final-comment.md — the body to post or update with
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
WORK_DIR = "pr-meta"
|
||||
|
||||
|
||||
def read_file(path):
|
||||
"""Read a file and return its stripped content, or None if missing/empty."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
content = f.read().strip()
|
||||
return content if content else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def read_findings_count(new_body):
|
||||
"""Return the findings count from findings-count.txt, or by counting bullets."""
|
||||
raw = read_file(os.path.join(WORK_DIR, "findings-count.txt"))
|
||||
if raw is not None:
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
# Fallback: count bullet lines before <details> (diff summary has its own bullets)
|
||||
body_before_details = new_body.split("<details>")[0]
|
||||
return len(re.findall(r"^- .+$", body_before_details, re.MULTILINE))
|
||||
|
||||
|
||||
def _was_already_passing(existing_body):
|
||||
"""Check if the most recent state in the comment is already all-clear."""
|
||||
# If there's a previous "all passing" edit, the last state was passing
|
||||
if re.search(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?: (?:- )?All checks are (now passing|passing now)", existing_body, re.MULTILINE):
|
||||
return True
|
||||
# If there are no edits at all, check the original comment body
|
||||
if not re.search(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?:", existing_body, re.MULTILINE):
|
||||
return "All our automated checks have passed" in existing_body
|
||||
return False
|
||||
|
||||
|
||||
def _previous_failing_count(existing_body):
|
||||
"""Extract the findings count from the most recent state in the comment."""
|
||||
# Check edit lines first (most recent state)
|
||||
matches = re.findall(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?: (?:- )?(\d+) checks? (?:is|are) still failing", existing_body, re.MULTILINE)
|
||||
if matches:
|
||||
return int(matches[-1])
|
||||
# No edits — count bullets in the original comment (before <details>)
|
||||
if not re.search(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?:", existing_body, re.MULTILINE):
|
||||
body_before_details = existing_body.split("<details>")[0]
|
||||
bullets = re.findall(r"^- .+$", body_before_details, re.MULTILINE)
|
||||
return len(bullets) if bullets else None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_pr_author(existing_body):
|
||||
"""Extract the PR author username from the 'Hello @user' greeting."""
|
||||
match = re.search(r"Hello @([\w-]+)", existing_body)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def build_edit_line(existing_body, findings_count, check_run_id, repo):
|
||||
"""Build the edit line to append, or None if nothing to do."""
|
||||
run_tag = f"<!-- run:{check_run_id} -->"
|
||||
|
||||
# Idempotency: this run was already processed
|
||||
if run_tag in existing_body:
|
||||
return None
|
||||
|
||||
# Skip if the state hasn't changed
|
||||
if findings_count == 0 and _was_already_passing(existing_body):
|
||||
return None
|
||||
if findings_count > 0 and _previous_failing_count(existing_body) == findings_count:
|
||||
return None
|
||||
|
||||
# Count previous edits to determine the next number
|
||||
edits = re.findall(r"^(?:\*\*)?Edit(?: \d+)?(?:\*\*)?:", existing_body, re.MULTILINE)
|
||||
edit_count = len(edits)
|
||||
next_edit = edit_count + 1
|
||||
|
||||
run_url = f"https://github.com/{repo}/actions/runs/{check_run_id}"
|
||||
|
||||
# Build resolved-issue prefix
|
||||
prev_count = _previous_failing_count(existing_body)
|
||||
resolved_prefix = ""
|
||||
if prev_count is not None and prev_count > findings_count:
|
||||
resolved = prev_count - findings_count
|
||||
noun = "issue was" if resolved == 1 else "issues were"
|
||||
resolved_prefix = f"{resolved} {noun} resolved, but "
|
||||
|
||||
if findings_count == 0:
|
||||
edit_label = "**Edit:**" if edit_count == 0 else f"**Edit {next_edit}:**"
|
||||
line = f"{edit_label} - All checks are now passing \U0001f389, see [here]({run_url}) for details {run_tag}"
|
||||
# Add thank-you when transitioning from failures to passing
|
||||
if prev_count is not None and prev_count > 0:
|
||||
author = _extract_pr_author(existing_body)
|
||||
if author:
|
||||
line += (
|
||||
f"\n\nThank you @{author} for fixing those issues! \U0001f607\n"
|
||||
f"This PR is now ready for human review, looping in @Lissy93 \U0001fae1"
|
||||
)
|
||||
return line
|
||||
|
||||
verb = "check is" if findings_count == 1 else "checks are"
|
||||
edit_label = f"**Edit {next_edit}:**"
|
||||
return (
|
||||
f"{edit_label} - {resolved_prefix}{findings_count} {verb} still failing, "
|
||||
f"see [here]({run_url}) for details {run_tag}"
|
||||
)
|
||||
|
||||
|
||||
def write_output(action, body=""):
|
||||
"""Write action.txt and (optionally) final-comment.md."""
|
||||
os.makedirs(WORK_DIR, exist_ok=True)
|
||||
with open(os.path.join(WORK_DIR, "action.txt"), "w") as f:
|
||||
f.write(action)
|
||||
if body:
|
||||
with open(os.path.join(WORK_DIR, "final-comment.md"), "w") as f:
|
||||
f.write(body)
|
||||
|
||||
|
||||
def main():
|
||||
check_run_id = os.environ.get("CHECK_RUN_ID", "")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
|
||||
new_body = read_file(os.path.join(WORK_DIR, "comment.md"))
|
||||
if not new_body:
|
||||
write_output("skip")
|
||||
return
|
||||
|
||||
existing_body = read_file(os.path.join(WORK_DIR, "existing-comment.md"))
|
||||
|
||||
# No existing comment — create a new one
|
||||
if not existing_body:
|
||||
write_output("create", new_body)
|
||||
return
|
||||
|
||||
# Existing comment — build an edit line to append
|
||||
if not check_run_id:
|
||||
write_output("skip")
|
||||
return
|
||||
|
||||
findings_count = read_findings_count(new_body)
|
||||
edit_line = build_edit_line(existing_body, findings_count, check_run_id, repo)
|
||||
|
||||
if not edit_line:
|
||||
write_output("skip")
|
||||
return
|
||||
|
||||
if "### Updates" in existing_body:
|
||||
updated = existing_body.rstrip() + "\n" + edit_line
|
||||
else:
|
||||
updated = existing_body.rstrip() + "\n\n---\n\n### Updates\n\n" + edit_line
|
||||
write_output("update", updated)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
jsonschema
|
||||
pyyaml
|
||||
termcolor
|
||||
jsonschema==4.23.0
|
||||
requests==2.32.3
|
||||
|
|
|
|||
|
|
@ -7,38 +7,64 @@
|
|||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 50 },
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
||||
"services": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"github": { "type": "string", "nullable": true },
|
||||
"icon": { "type": "string", "nullable": true },
|
||||
"followWith": { "type": "string", "nullable": true },
|
||||
"securityAudited": { "type": "boolean", "nullable": true },
|
||||
"openSource": { "type": "boolean", "nullable": true },
|
||||
"acceptsCrypto": { "type": "boolean", "nullable": true },
|
||||
"tosdrId": { "type": "number", "nullable": true },
|
||||
"iosApp": { "type": "string", "nullable": true },
|
||||
"androidApp": { "type": "string", "nullable": true },
|
||||
"discordInvite": { "type": "string", "nullable": true },
|
||||
"subreddit": { "type": "string", "nullable": true }
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
||||
"description": { "type": "string", "minLength": 10, "maxLength": 1500 },
|
||||
"url": {
|
||||
"type": "string",
|
||||
"anyOf": [
|
||||
{ "pattern": "^https?://" },
|
||||
{ "maxLength": 0 }
|
||||
]
|
||||
},
|
||||
"github": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^([a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+|https://github\\.com/.+)$"
|
||||
},
|
||||
"icon": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^https?://.+"
|
||||
},
|
||||
"followWith": { "type": ["string", "null"], "minLength": 1, "maxLength": 100 },
|
||||
"securityAudited": { "type": ["boolean", "null"] },
|
||||
"openSource": { "type": ["boolean", "null"] },
|
||||
"acceptsCrypto": { "type": ["boolean", "null"] },
|
||||
"tosdrId": { "type": ["integer", "null"], "minimum": 1 },
|
||||
"iosApp": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^https://apps\\.apple\\.com/"
|
||||
},
|
||||
"androidApp": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$"
|
||||
},
|
||||
"discordInvite": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^(https://discord\\.gg/[a-zA-Z0-9]+|[a-zA-Z0-9]+|)$"
|
||||
},
|
||||
"subreddit": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[a-zA-Z0-9_]+$",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
},
|
||||
"required": ["name", "description", "url"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"intro": { "type": "string", "nullable": true },
|
||||
"intro": { "type": ["string", "null"], "minLength": 1 },
|
||||
"notableMentions": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
|
@ -46,24 +72,29 @@
|
|||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"url": { "type": "string" }
|
||||
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
||||
"description": { "type": "string", "minLength": 1 },
|
||||
"url": { "type": "string", "pattern": "^https?://" }
|
||||
},
|
||||
"required": ["name", "url"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{ "type": "string" }
|
||||
],
|
||||
"nullable": true
|
||||
{ "type": "string", "minLength": 1 },
|
||||
{ "type": "null" }
|
||||
]
|
||||
},
|
||||
"furtherInfo": { "type": "string", "nullable": true },
|
||||
"wordOfWarning": { "type": "string", "nullable": true },
|
||||
"furtherInfo": { "type": ["string", "null"], "minLength": 1 },
|
||||
"wordOfWarning": { "type": ["string", "null"], "minLength": 1 },
|
||||
"alternativeTo": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"nullable": true
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 100 },
|
||||
"minItems": 1
|
||||
},
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["name", "services"],
|
||||
|
|
|
|||
|
|
@ -1,85 +1,112 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
from termcolor import colored
|
||||
from jsonschema import Draft7Validator
|
||||
|
||||
# Configure Logging
|
||||
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(level=LOG_LEVEL)
|
||||
logger = logging.getLogger(__name__)
|
||||
# Paths (relative to project root)
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_PATH = os.path.join(PROJECT_ROOT, "awesome-privacy.yml")
|
||||
SCHEMA_PATH = os.path.join(PROJECT_ROOT, "lib/schema.json")
|
||||
|
||||
# Exit codes
|
||||
EXIT_VALID = 0
|
||||
EXIT_VALIDATION_ERRORS = 1
|
||||
EXIT_RUNTIME_ERROR = 2
|
||||
|
||||
MAX_ERRORS = 20
|
||||
|
||||
# ANSI color helpers (disabled when NO_COLOR is set or stderr is not a TTY)
|
||||
_use_color = sys.stderr.isatty() and not os.environ.get("NO_COLOR")
|
||||
red = (lambda s: f"\033[31m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
green = (lambda s: f"\033[32m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
yellow = (lambda s: f"\033[33m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
dim = (lambda s: f"\033[2m{s}\033[0m") if _use_color else (lambda s: s)
|
||||
|
||||
|
||||
# Determine the project root based on the script's location
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
awesome_privacy_path = os.path.join(project_root, 'awesome-privacy.yml')
|
||||
schema_path = os.path.join(project_root, 'lib/schema.json')
|
||||
def resolve_path(data, path_parts):
|
||||
"""Walk the data along path_parts, replacing indices with 'name' values."""
|
||||
segments = []
|
||||
current = data
|
||||
for part in path_parts:
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
if isinstance(current, dict) and "name" in current:
|
||||
segments.append(current["name"])
|
||||
elif not isinstance(part, int):
|
||||
pass # skip dict keys like 'categories', 'sections', 'services'
|
||||
elif isinstance(current, list) and isinstance(part, int) and part < len(current):
|
||||
current = current[part]
|
||||
if isinstance(current, dict) and "name" in current:
|
||||
segments.append(current["name"])
|
||||
else:
|
||||
segments.append(str(part))
|
||||
else:
|
||||
segments.append(str(part))
|
||||
break
|
||||
return " > ".join(segments) if segments else "(root)"
|
||||
|
||||
|
||||
# Log method, accepts a message and optional log level
|
||||
# and prints the output to the terminal in right color
|
||||
def loggy(message: str, level: str = 'debug'):
|
||||
if level == "info":
|
||||
logger.info(colored(message, 'blue'))
|
||||
elif level == "warning":
|
||||
logger.warning(colored(message, 'yellow'))
|
||||
elif level == "error":
|
||||
logger.error(colored(message, 'red'))
|
||||
elif level == "success":
|
||||
logger.info(colored(message, 'green'))
|
||||
elif level == "debug":
|
||||
logger.debug(colored(message, 'grey'))
|
||||
|
||||
|
||||
# Loads a given YAML file and returns the data
|
||||
def load_yaml(yaml_path: str):
|
||||
loggy(f"Loading YAML from {yaml_path}", "info")
|
||||
def load_yaml(path):
|
||||
try:
|
||||
with open(yaml_path, 'r') as file:
|
||||
return yaml.safe_load(file)
|
||||
with open(path, "r") as f:
|
||||
return yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
print(red(f"File not found: {path}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
except yaml.YAMLError as e:
|
||||
loggy(f"Failed to load YAML: {e}", "error")
|
||||
sys.exit(1)
|
||||
print(red(f"Failed to parse YAML: {e}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
|
||||
|
||||
# Loads a given JSON Schema file and returns the data
|
||||
def load_schema(schema_path: str):
|
||||
loggy(f"Loading JSON Schema from {schema_path}", "info")
|
||||
def load_schema(path):
|
||||
try:
|
||||
with open(schema_path, 'r') as file:
|
||||
return json.load(file)
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
print(red(f"File not found: {path}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
except json.JSONDecodeError as e:
|
||||
loggy(f"Failed to load JSON Schema: {e}", "error")
|
||||
sys.exit(1)
|
||||
print(red(f"Failed to parse JSON schema: {e}"), file=sys.stderr)
|
||||
sys.exit(EXIT_RUNTIME_ERROR)
|
||||
|
||||
|
||||
# Validates the given YAML data against the given JSON Schema
|
||||
def validate_yaml(data, schema):
|
||||
loggy("Beginning validation", "info")
|
||||
def validate(data, schema):
|
||||
validator = Draft7Validator(schema)
|
||||
errors = sorted(validator.iter_errors(data), key=lambda e: e.path)
|
||||
if errors:
|
||||
for error in errors:
|
||||
error_location = "->".join(map(str, error.path))
|
||||
loggy(f"Validation error: {error.message} (at {error_location})", "warning")
|
||||
return False
|
||||
return True
|
||||
errors = sorted(validator.iter_errors(data), key=lambda e: list(e.path))
|
||||
formatted = []
|
||||
for error in errors:
|
||||
location = resolve_path(data, list(error.path))
|
||||
formatted.append(f"{location}: {error.message}")
|
||||
return formatted
|
||||
|
||||
|
||||
# Main method
|
||||
def main():
|
||||
loggy("Starting...", "info")
|
||||
yaml_data = load_yaml(awesome_privacy_path)
|
||||
schema = load_schema(schema_path)
|
||||
data = load_yaml(DATA_PATH)
|
||||
schema = load_schema(SCHEMA_PATH)
|
||||
errors = validate(data, schema)
|
||||
|
||||
if validate_yaml(yaml_data, schema):
|
||||
loggy("Validation successful!", "success")
|
||||
sys.exit(0)
|
||||
else:
|
||||
loggy("Validation failed.", "error")
|
||||
sys.exit(1)
|
||||
if errors:
|
||||
shown = errors[:MAX_ERRORS]
|
||||
for msg in shown:
|
||||
print(red("ERROR") + " " + msg, file=sys.stderr)
|
||||
if len(errors) > MAX_ERRORS:
|
||||
print(dim(f"...and {len(errors) - MAX_ERRORS} more"), file=sys.stderr)
|
||||
print(red(f"Validation failed: {len(errors)} error(s)"), file=sys.stderr)
|
||||
sys.exit(EXIT_VALIDATION_ERRORS)
|
||||
|
||||
# Gather stats
|
||||
categories = data.get("categories", [])
|
||||
num_categories = len(categories)
|
||||
num_sections = sum(len(c.get("sections", [])) for c in categories)
|
||||
num_services = sum(
|
||||
len(s.get("services", []))
|
||||
for c in categories
|
||||
for s in c.get("sections", [])
|
||||
)
|
||||
print(green(f"Valid! {num_categories} categories, {num_sections} sections, {num_services} services"))
|
||||
sys.exit(EXIT_VALID)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
14
web/.editorconfig
Normal file
14
web/.editorconfig
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.astro]
|
||||
indent_style = tab
|
||||
|
||||
[*.{ts,js,svelte,scss,json}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
2
web/.gitignore
vendored
2
web/.gitignore
vendored
|
|
@ -19,3 +19,5 @@ dist/
|
|||
|
||||
# macOS crap
|
||||
.DS_Store
|
||||
|
||||
.vscode/
|
||||
|
|
|
|||
1
web/.nvmrc
Normal file
1
web/.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24.11.0
|
||||
17
web/.prettierignore
Normal file
17
web/.prettierignore
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
dist/
|
||||
.astro/
|
||||
node_modules/
|
||||
.vercel/
|
||||
yarn.lock
|
||||
public/
|
||||
*.md
|
||||
|
||||
# Astro files with adjacent JSX elements that prettier-plugin-astro cannot parse
|
||||
src/components/things/DockerDetailedInfo.astro
|
||||
src/components/things/GitHubDetailedInfo.astro
|
||||
src/components/things/IosAppDetailedInfo.astro
|
||||
src/components/things/ItemGitHubMetrics.astro
|
||||
src/components/things/PrivacyPolicyDetails.astro
|
||||
src/components/things/WebsiteDetailedInfo.astro
|
||||
src/pages/*section*.astro
|
||||
src/pages/all.astro
|
||||
22
web/.prettierrc
Normal file
22
web/.prettierrc
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-astro", "prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.astro",
|
||||
"options": {
|
||||
"parser": "astro"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.js", "*.svelte", "*.scss"],
|
||||
"options": {
|
||||
"useTabs": false,
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
web/.vscode/extensions.json
vendored
4
web/.vscode/extensions.json
vendored
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
web/.vscode/launch.json
vendored
11
web/.vscode/launch.json
vendored
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
npmScopes:
|
||||
fortawesome:
|
||||
npmAlwaysAuth: true
|
||||
npmRegistryServer: "https://npm.fontawesome.com/"
|
||||
npmRegistryServer: 'https://npm.fontawesome.com/'
|
||||
npmAuthToken: ECB95473-FBAF-463F-905C-C9ED4C00D519
|
||||
awesome:
|
||||
npmAlwaysAuth: true
|
||||
npmRegistryServer: "https://npm.fontawesome.com/"
|
||||
npmRegistryServer: 'https://npm.fontawesome.com/'
|
||||
npmAuthToken: ECB95473-FBAF-463F-905C-C9ED4C00D519
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { defineConfig } from 'astro/config';
|
|||
import svelte from '@astrojs/svelte';
|
||||
import partytown from '@astrojs/partytown';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { printSummary } from './src/utils/logger.ts';
|
||||
|
||||
// Adapters
|
||||
import vercelAdapter from '@astrojs/vercel/serverless';
|
||||
|
|
@ -21,17 +22,36 @@ const output = import.meta.env.OUTPUT || 'hybrid';
|
|||
const site = import.meta.env.SITE_URL || 'https://awesome-privacy.xyz';
|
||||
|
||||
// Initialize Astro integrations
|
||||
const integrations = [svelte(), partytown(), sitemap()];
|
||||
const buildLogger = {
|
||||
name: 'build-logger',
|
||||
hooks: {
|
||||
'astro:build:done': () => printSummary(),
|
||||
},
|
||||
};
|
||||
|
||||
const integrations = [svelte(), partytown(), sitemap(), buildLogger];
|
||||
|
||||
// Set the appropriate adapter, based on the deploy target
|
||||
const adapter = {
|
||||
vercel: vercelAdapter,
|
||||
netlify: netlifyAdapter,
|
||||
cloudflare: cloudflareAdapter,
|
||||
node: nodeAdapter({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
vercel: vercelAdapter,
|
||||
netlify: netlifyAdapter,
|
||||
cloudflare: cloudflareAdapter,
|
||||
node: nodeAdapter({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
}[deployTarget]();
|
||||
|
||||
// Export Astro configuration
|
||||
export default defineConfig({ output, integrations, site, adapter });
|
||||
export default defineConfig({
|
||||
output,
|
||||
integrations,
|
||||
site,
|
||||
adapter,
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: { api: 'modern' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
80
web/eslint.config.js
Normal file
80
web/eslint.config.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import eslintPluginAstro from 'eslint-plugin-astro';
|
||||
import eslintPluginSvelte from 'eslint-plugin-svelte';
|
||||
import svelteParser from 'svelte-eslint-parser';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
// Global ignores
|
||||
{ ignores: ['dist/', '.astro/', 'node_modules/', '.vercel/'] },
|
||||
|
||||
// Base JS config
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// Astro
|
||||
...eslintPluginAstro.configs.recommended,
|
||||
|
||||
// Svelte — with TypeScript parser for <script lang="ts">
|
||||
...eslintPluginSvelte.configs['flat/recommended'].map((config) =>
|
||||
config.files
|
||||
? {
|
||||
...config,
|
||||
languageOptions: {
|
||||
...config.languageOptions,
|
||||
parser: svelteParser,
|
||||
parserOptions: {
|
||||
...config.languageOptions?.parserOptions,
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
}
|
||||
: config,
|
||||
),
|
||||
|
||||
// Global settings
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'no-console': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'no-useless-assignment': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// Allow triple-slash references in env.d.ts (Astro convention)
|
||||
{
|
||||
files: ['src/env.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/triple-slash-reference': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Lenient Svelte rules — existing code uses these patterns intentionally
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
rules: {
|
||||
'svelte/no-at-html-tags': 'warn',
|
||||
'svelte/require-each-key': 'warn',
|
||||
'svelte/no-dom-manipulating': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// Prettier must be last to override conflicting rules
|
||||
eslintConfigPrettier,
|
||||
];
|
||||
103
web/package.json
103
web/package.json
|
|
@ -1,37 +1,70 @@
|
|||
{
|
||||
"name": "web",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro preview",
|
||||
"build": "astro check && astro build",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.5.4",
|
||||
"@astrojs/netlify": "^5.1.2",
|
||||
"@astrojs/partytown": "^2.0.4",
|
||||
"@astrojs/sitemap": "^3.1.0",
|
||||
"@astrojs/svelte": "^5.0.3",
|
||||
"@astrojs/vercel": "^7.3.2",
|
||||
"@fortawesome/fontawesome-pro": "^6.5.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/svelte-fontawesome": "^0.2.2",
|
||||
"astro": "^4.3.6",
|
||||
"fuse.js": "^7.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"marked": "^12.0.0",
|
||||
"svelte": "^4.2.11",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/cloudflare": "^9.0.1",
|
||||
"@astrojs/node": "^8.2.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.11.19",
|
||||
"sass": "^1.70.0"
|
||||
}
|
||||
"name": "web",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro preview",
|
||||
"build": "astro check && astro build",
|
||||
"astro": "astro",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "astro check",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check:all": "astro check && eslint . && prettier --check . && vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@astrojs/netlify": "^5.5.4",
|
||||
"@astrojs/partytown": "^2.1.4",
|
||||
"@astrojs/sitemap": "3.5.1",
|
||||
"@astrojs/svelte": "^5.7.3",
|
||||
"@astrojs/vercel": "^7.8.2",
|
||||
"@fortawesome/fontawesome-pro": "^6.5.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/svelte-fontawesome": "^0.2.2",
|
||||
"astro": "^4.16.19",
|
||||
"fuse.js": "^7.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"marked": "^12.0.2",
|
||||
"svelte": "^4.2.19",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/cloudflare": "^11.2.0",
|
||||
"@astrojs/node": "^8.3.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.11.19",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-astro": "^1.6.0",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"sass": "^1.97.3",
|
||||
"svelte-eslint-parser": "^1.5.1",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"resolutions": {
|
||||
"braces": ">=3.0.3",
|
||||
"micromatch": ">=4.0.8",
|
||||
"minimatch": ">=3.1.3",
|
||||
"prismjs": ">=1.30.0",
|
||||
"mdast-util-to-hast": ">=13.2.1",
|
||||
"dset": ">=3.1.4",
|
||||
"esbuild": ">=0.25.0",
|
||||
"undici": ">=6.23.0",
|
||||
"lodash": ">=4.17.23",
|
||||
"**/js-yaml": ">=4.1.1",
|
||||
"brace-expansion": ">=2.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,19 +21,18 @@ const { href, title, body } = Astro.props;
|
|||
</li>
|
||||
<style lang="scss">
|
||||
.link-card {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: 700;
|
||||
transition: all ease-in-out 0.1s;
|
||||
list-style: none;
|
||||
&:hover {
|
||||
box-shadow: 8px 8px 0 var(--box-outline);
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
|
||||
}
|
||||
a {
|
||||
box-sizing: border-box;
|
||||
|
|
@ -45,5 +44,4 @@ const { href, title, body } = Astro.props;
|
|||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,61 +1,60 @@
|
|||
---
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte"
|
||||
import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte"
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
import ThemeSwitcher from '@components/form/ThemeSwitcher.svelte';
|
||||
---
|
||||
|
||||
<div class="theme-switcher">
|
||||
<ThemeSwitcher client:load />
|
||||
<ThemeSwitcher client:load />
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<h1>Awesome Privacy</h1>
|
||||
<p class="intro">
|
||||
Your guide to finding and comparing privacy-respecting alternatives to popular software and services.
|
||||
</p>
|
||||
<div class="github-link-wrap">
|
||||
<a href="https://github.com/lissy93/awesome-privacy">
|
||||
<FontAwesome iconName="github" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
<h1>Awesome Privacy</h1>
|
||||
<p class="intro">
|
||||
Your guide to finding and comparing privacy-respecting alternatives to
|
||||
popular software and services.
|
||||
</p>
|
||||
<div class="github-link-wrap">
|
||||
<a href="https://github.com/lissy93/awesome-privacy">
|
||||
<FontAwesome iconName="github" />
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="top-right">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/search">Search</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/browse">Browse</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about">About</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://as93.net">More Apps</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/search">Search</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/browse">Browse</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about">About</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://as93.net">More Apps</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.hero {
|
||||
color: var(--accent-fg);
|
||||
border-radius: var(--curve-sm);
|
||||
padding: 2rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
@media(max-width: 768px) {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
color: var(--accent-fg);
|
||||
border-radius: var(--curve-sm);
|
||||
padding: 2rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
@media (max-width: 768px) {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
position: absolute;
|
||||
|
|
@ -65,98 +64,97 @@ import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte"
|
|||
width: 220px;
|
||||
height: auto;
|
||||
opacity: 0.6;
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
font-size: 5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
font-family: 'Libre Franklin', sans-serif;
|
||||
color: var(--accent-3);
|
||||
-webkit-text-fill-color: var(--accent-3);
|
||||
-webkit-text-stroke-width: 2px;
|
||||
-webkit-text-stroke-color: var(--box-outline);
|
||||
text-shadow: 3px 3px 0 var(--box-outline);
|
||||
@media(max-width: 768px) {
|
||||
font-size: 4rem;
|
||||
}
|
||||
font-family: 'Libre Franklin', sans-serif;
|
||||
color: var(--accent-3);
|
||||
-webkit-text-fill-color: var(--accent-3);
|
||||
-webkit-text-stroke-width: 2px;
|
||||
-webkit-text-stroke-color: var(--box-outline);
|
||||
text-shadow: 3px 3px 0 var(--box-outline);
|
||||
@media (max-width: 768px) {
|
||||
font-size: 4rem;
|
||||
}
|
||||
}
|
||||
.intro {
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-3);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: 700;
|
||||
max-width: 735px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.github-link-wrap {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
max-width: 735px;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--box-outline);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
background: var(--accent);
|
||||
border-radius: 18px;
|
||||
padding: 0.5rem 1rem;
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--accent-fg);
|
||||
font-size: 1.2rem;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
:global(svg) {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--accent-fg);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.intro {
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-3);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: 700;
|
||||
max-width: 735px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.github-link-wrap {
|
||||
font-family: "Lekton", sans-serif;
|
||||
max-width: 735px;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--box-outline);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
background: var(--accent);
|
||||
border-radius: 18px;
|
||||
padding: 0.5rem 1rem;
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--accent-fg);;
|
||||
font-size: 1.2rem;
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
:global(svg) {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--accent-fg);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-switcher {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
}
|
||||
.theme-switcher {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 1rem;
|
||||
opacity: 0.8;
|
||||
display: none;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--accent);
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
li a {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-right {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 1rem;
|
||||
opacity: 0.8;
|
||||
display: none;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--accent);
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
li a {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,30 @@
|
|||
---
|
||||
|
||||
const {
|
||||
text,
|
||||
url,
|
||||
className,
|
||||
title,
|
||||
} = Astro.props;
|
||||
|
||||
const { text, url, className, title } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`button ${className || ''}`} title={title}>
|
||||
<a href={url}>{text}<slot /></a>
|
||||
<a href={url}>{text}<slot /></a>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.button {
|
||||
font-family: "Lekton", sans-serif;
|
||||
text-align: center;
|
||||
border: 1px solid var(--box-outline);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
background: var(--accent);
|
||||
border-radius: 18px;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--accent-fg);;
|
||||
font-size: 1.2rem;
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.button {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
text-align: center;
|
||||
border: 1px solid var(--box-outline);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
background: var(--accent);
|
||||
border-radius: 18px;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--accent-fg);
|
||||
font-size: 1.2rem;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -41,25 +41,27 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClickOutside}/>
|
||||
<svelte:window on:click={handleClickOutside} />
|
||||
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<div>
|
||||
<h2
|
||||
contenteditable={true}
|
||||
class:editable={editing}
|
||||
on:click={() => editing = true}
|
||||
on:keydown={handleKeydown}
|
||||
on:blur={() => saveTitle(title)}
|
||||
tabindex="0"
|
||||
>{title}</h2>
|
||||
<h2
|
||||
contenteditable={true}
|
||||
class:editable={editing}
|
||||
on:click={() => (editing = true)}
|
||||
on:keydown={handleKeydown}
|
||||
on:blur={() => saveTitle(title)}
|
||||
tabindex="0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<small>Click the title, to edit your inventory name</small>
|
||||
<small>Click the title, to edit your inventory name</small>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h2 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
import * as brands from '@fortawesome/free-brands-svg-icons';
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
|
||||
export const iconMap: Record<string, IconDefinition> = {
|
||||
// Branding
|
||||
logo: solidIcons.faEyeSlash,
|
||||
|
|
@ -83,13 +82,8 @@
|
|||
};
|
||||
|
||||
export let iconName: string;
|
||||
|
||||
</script>
|
||||
|
||||
{#if iconMap[iconName]}
|
||||
<FontAwesomeIcon
|
||||
class="fa-icon"
|
||||
icon={iconMap[iconName]} />
|
||||
<FontAwesomeIcon class="fa-icon" icon={iconMap[iconName]} />
|
||||
{/if}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,73 +1,84 @@
|
|||
---
|
||||
|
||||
interface IconProps {
|
||||
icon: string;
|
||||
color?: string;
|
||||
class?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
icon: string;
|
||||
color?: string;
|
||||
class?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const getSvgPath = (icon: string) => {
|
||||
switch (icon) {
|
||||
case 'star':
|
||||
return {
|
||||
vb: "0 0 24 24",
|
||||
path: "M10 15l-5.5 3 1-5.5L0 7.5l5.6-0.5L10 2l2 5 5.5 0.5-4 4 1 5.5z",
|
||||
};
|
||||
case 'mastodon':
|
||||
return {
|
||||
vb: "0 0 512 512",
|
||||
path: "M433 179.1c0-97.2-63.7-125.7-63.7-125.7-62.5-28.7-228.6-28.4-290.5 0 0 0-63.7 28.5-63.7 125.7 0 115.7-6.6 259.4 105.6 289.1 40.5 10.7 75.3 13 103.3 11.4 50.8-2.8 79.3-18.1 79.3-18.1l-1.7-36.9s-36.3 11.4-77.1 10.1c-40.4-1.4-83-4.4-89.6-54a102.5 102.5 0 0 1 -.9-13.9c85.6 20.9 158.7 9.1 178.8 6.7 56.1-6.7 105-41.3 111.2-72.9 9.8-49.8 9-121.5 9-121.5zm-75.1 125.2h-46.6v-114.2c0-49.7-64-51.6-64 6.9v62.5h-46.3V197c0-58.5-64-56.6-64-6.9v114.2H90.2c0-122.1-5.2-147.9 18.4-175 25.9-28.9 79.8-30.8 103.8 6.1l11.6 19.5 11.6-19.5c24.1-37.1 78.1-34.8 103.8-6.1 23.7 27.3 18.4 53 18.4 175z",
|
||||
};
|
||||
case 'twitter':
|
||||
return {
|
||||
vb: "0 0 512 512",
|
||||
path: "M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z",
|
||||
};
|
||||
case 'hub':
|
||||
return {
|
||||
vb: "0 0 512 512",
|
||||
path: "M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z",
|
||||
};
|
||||
case 'dev':
|
||||
return {
|
||||
vb: "0 0 512 512",
|
||||
path: "M120.1 208.3c-3.9-2.9-7.8-4.4-11.7-4.4H91v104.5h17.5c3.9 0 7.8-1.5 11.7-4.4 3.9-2.9 5.8-7.3 5.8-13.1v-69.7c0-5.8-2-10.2-5.8-13.1zM404.1 32H43.9C19.7 32 .1 51.6 0 75.8v360.4C.1 460.4 19.7 480 43.9 480h360.2c24.2 0 43.8-19.6 43.9-43.8V75.8c-.1-24.2-19.7-43.8-43.9-43.8zM154.2 291.2c0 18.8-11.6 47.3-48.4 47.3h-46.4V173h47.4c35.4 0 47.4 28.5 47.4 47.3l0 70.9zm100.7-88.7H201.6v38.4h32.6v29.6H201.6v38.4h53.3v29.6h-62.2c-11.2 .3-20.4-8.5-20.7-19.7V193.7c-.3-11.2 8.6-20.4 19.7-20.7h63.2l0 29.5zm103.6 115.3c-13.2 30.8-36.9 24.6-47.4 0l-38.5-144.8h32.6l29.7 113.7 29.6-113.7h32.6l-38.5 144.8z",
|
||||
};
|
||||
case 'linkedin':
|
||||
return {
|
||||
vb: "0 0 512 512",
|
||||
path: "M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z",
|
||||
};
|
||||
case 'essentials':
|
||||
return {
|
||||
vb: "0 0 512 512",
|
||||
path: "M208 176c0-70.7 57.3-128 128-128s128 57.3 128 128s-57.3 128-128 128c-10.4 0-20.5-1.2-30.1-3.6c-8.1-2-16.7 .4-22.6 6.4L254.1 336H200c-13.3 0-24 10.7-24 24v40H136c-13.3 0-24 10.7-24 24v40H48V385.9L205.2 228.7c5.9-5.9 8.3-14.5 6.4-22.6c-2.3-9.6-3.6-19.7-3.6-30.1zM336 0C238.8 0 160 78.8 160 176c0 9.5 .7 18.8 2.2 27.9L7 359c-4.5 4.5-7 10.6-7 17V488c0 13.3 10.7 24 24 24H136c13.3 0 24-10.7 24-24V448h40c13.3 0 24-10.7 24-24V384h40c6.4 0 12.5-2.5 17-7l27.2-27.2c9.1 1.4 18.4 2.2 27.9 2.2c97.2 0 176-78.8 176-176S433.2 0 336 0zm32 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64z",
|
||||
};
|
||||
case 'communication':
|
||||
return {
|
||||
vb: "0 0 640 512",
|
||||
path: "M48 72c0-13.3 10.7-24 24-24H344c13.3 0 24 10.7 24 24V248c0 13.3-10.7 24-24 24H216c-4.7 0-9.4 1.4-13.3 4L144 315.2V296c0-13.3-10.7-24-24-24H72c-13.3 0-24-10.7-24-24V72zM72 0C32.2 0 0 32.2 0 72V248c0 39.8 32.2 72 72 72H96v40c0 8.9 4.9 17 12.7 21.2s17.3 3.7 24.6-1.2l90-60H344c39.8 0 72-32.2 72-72V72c0-39.8-32.2-72-72-72H72zM256 376c0 39.8 32.2 72 72 72h88.7l90 60c7.4 4.9 16.8 5.4 24.6 1.2S544 496.9 544 488V448h24c39.8 0 72-32.2 72-72V200c0-39.8-32.2-72-72-72H448v48H568c13.3 0 24 10.7 24 24V376c0 13.3-10.7 24-24 24H520c-13.3 0-24 10.7-24 24v19.2L437.3 404c-3.9-2.6-8.6-4-13.3-4H328c-13.3 0-24-10.7-24-24V352H256v24z",
|
||||
};
|
||||
case 'security-tools':
|
||||
return {
|
||||
vp: "0 0 512 512",
|
||||
path: "M232 60.8V447.4c-66.9-37.8-108.8-94.3-134.1-152.6C71 232.9 63.1 169.5 64.1 126L232 60.8zm48 386.5V60.8L448 126c1 43.5-6.9 106.9-33.8 168.8C388.8 353.1 346.9 409.5 280 447.3zM495.5 113l-1.2-20.5L475.1 85 267.6 4.5 256 0 244.4 4.5 36.9 85 17.8 92.5 16.6 113c-2.9 49.9 4.9 126.3 37.3 200.9c32.7 75.2 91 150 189.4 192.6L256 512l12.7-5.5c98.4-42.6 156.7-117.3 189.4-192.6c32.4-74.7 40.2-151 37.3-200.9z"
|
||||
};
|
||||
// Add more icons as needed...
|
||||
default:
|
||||
return { vb: "", path: "" }; // Default path or a placeholder icon
|
||||
}
|
||||
switch (icon) {
|
||||
case 'star':
|
||||
return {
|
||||
vb: '0 0 24 24',
|
||||
path: 'M10 15l-5.5 3 1-5.5L0 7.5l5.6-0.5L10 2l2 5 5.5 0.5-4 4 1 5.5z',
|
||||
};
|
||||
case 'mastodon':
|
||||
return {
|
||||
vb: '0 0 512 512',
|
||||
path: 'M433 179.1c0-97.2-63.7-125.7-63.7-125.7-62.5-28.7-228.6-28.4-290.5 0 0 0-63.7 28.5-63.7 125.7 0 115.7-6.6 259.4 105.6 289.1 40.5 10.7 75.3 13 103.3 11.4 50.8-2.8 79.3-18.1 79.3-18.1l-1.7-36.9s-36.3 11.4-77.1 10.1c-40.4-1.4-83-4.4-89.6-54a102.5 102.5 0 0 1 -.9-13.9c85.6 20.9 158.7 9.1 178.8 6.7 56.1-6.7 105-41.3 111.2-72.9 9.8-49.8 9-121.5 9-121.5zm-75.1 125.2h-46.6v-114.2c0-49.7-64-51.6-64 6.9v62.5h-46.3V197c0-58.5-64-56.6-64-6.9v114.2H90.2c0-122.1-5.2-147.9 18.4-175 25.9-28.9 79.8-30.8 103.8 6.1l11.6 19.5 11.6-19.5c24.1-37.1 78.1-34.8 103.8-6.1 23.7 27.3 18.4 53 18.4 175z',
|
||||
};
|
||||
case 'twitter':
|
||||
return {
|
||||
vb: '0 0 512 512',
|
||||
path: 'M389.2 48h70.6L305.6 224.2 487 464H345L233.7 318.6 106.5 464H35.8L200.7 275.5 26.8 48H172.4L272.9 180.9 389.2 48zM364.4 421.8h39.1L151.1 88h-42L364.4 421.8z',
|
||||
};
|
||||
case 'hub':
|
||||
return {
|
||||
vb: '0 0 512 512',
|
||||
path: 'M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z',
|
||||
};
|
||||
case 'dev':
|
||||
return {
|
||||
vb: '0 0 512 512',
|
||||
path: 'M120.1 208.3c-3.9-2.9-7.8-4.4-11.7-4.4H91v104.5h17.5c3.9 0 7.8-1.5 11.7-4.4 3.9-2.9 5.8-7.3 5.8-13.1v-69.7c0-5.8-2-10.2-5.8-13.1zM404.1 32H43.9C19.7 32 .1 51.6 0 75.8v360.4C.1 460.4 19.7 480 43.9 480h360.2c24.2 0 43.8-19.6 43.9-43.8V75.8c-.1-24.2-19.7-43.8-43.9-43.8zM154.2 291.2c0 18.8-11.6 47.3-48.4 47.3h-46.4V173h47.4c35.4 0 47.4 28.5 47.4 47.3l0 70.9zm100.7-88.7H201.6v38.4h32.6v29.6H201.6v38.4h53.3v29.6h-62.2c-11.2 .3-20.4-8.5-20.7-19.7V193.7c-.3-11.2 8.6-20.4 19.7-20.7h63.2l0 29.5zm103.6 115.3c-13.2 30.8-36.9 24.6-47.4 0l-38.5-144.8h32.6l29.7 113.7 29.6-113.7h32.6l-38.5 144.8z',
|
||||
};
|
||||
case 'linkedin':
|
||||
return {
|
||||
vb: '0 0 512 512',
|
||||
path: 'M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z',
|
||||
};
|
||||
case 'essentials':
|
||||
return {
|
||||
vb: '0 0 512 512',
|
||||
path: 'M208 176c0-70.7 57.3-128 128-128s128 57.3 128 128s-57.3 128-128 128c-10.4 0-20.5-1.2-30.1-3.6c-8.1-2-16.7 .4-22.6 6.4L254.1 336H200c-13.3 0-24 10.7-24 24v40H136c-13.3 0-24 10.7-24 24v40H48V385.9L205.2 228.7c5.9-5.9 8.3-14.5 6.4-22.6c-2.3-9.6-3.6-19.7-3.6-30.1zM336 0C238.8 0 160 78.8 160 176c0 9.5 .7 18.8 2.2 27.9L7 359c-4.5 4.5-7 10.6-7 17V488c0 13.3 10.7 24 24 24H136c13.3 0 24-10.7 24-24V448h40c13.3 0 24-10.7 24-24V384h40c6.4 0 12.5-2.5 17-7l27.2-27.2c9.1 1.4 18.4 2.2 27.9 2.2c97.2 0 176-78.8 176-176S433.2 0 336 0zm32 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64z',
|
||||
};
|
||||
case 'communication':
|
||||
return {
|
||||
vb: '0 0 640 512',
|
||||
path: 'M48 72c0-13.3 10.7-24 24-24H344c13.3 0 24 10.7 24 24V248c0 13.3-10.7 24-24 24H216c-4.7 0-9.4 1.4-13.3 4L144 315.2V296c0-13.3-10.7-24-24-24H72c-13.3 0-24-10.7-24-24V72zM72 0C32.2 0 0 32.2 0 72V248c0 39.8 32.2 72 72 72H96v40c0 8.9 4.9 17 12.7 21.2s17.3 3.7 24.6-1.2l90-60H344c39.8 0 72-32.2 72-72V72c0-39.8-32.2-72-72-72H72zM256 376c0 39.8 32.2 72 72 72h88.7l90 60c7.4 4.9 16.8 5.4 24.6 1.2S544 496.9 544 488V448h24c39.8 0 72-32.2 72-72V200c0-39.8-32.2-72-72-72H448v48H568c13.3 0 24 10.7 24 24V376c0 13.3-10.7 24-24 24H520c-13.3 0-24 10.7-24 24v19.2L437.3 404c-3.9-2.6-8.6-4-13.3-4H328c-13.3 0-24-10.7-24-24V352H256v24z',
|
||||
};
|
||||
case 'security-tools':
|
||||
return {
|
||||
vp: '0 0 512 512',
|
||||
path: 'M232 60.8V447.4c-66.9-37.8-108.8-94.3-134.1-152.6C71 232.9 63.1 169.5 64.1 126L232 60.8zm48 386.5V60.8L448 126c1 43.5-6.9 106.9-33.8 168.8C388.8 353.1 346.9 409.5 280 447.3zM495.5 113l-1.2-20.5L475.1 85 267.6 4.5 256 0 244.4 4.5 36.9 85 17.8 92.5 16.6 113c-2.9 49.9 4.9 126.3 37.3 200.9c32.7 75.2 91 150 189.4 192.6L256 512l12.7-5.5c98.4-42.6 156.7-117.3 189.4-192.6c32.4-74.7 40.2-151 37.3-200.9z',
|
||||
};
|
||||
// Add more icons as needed...
|
||||
default:
|
||||
return { vb: '', path: '' }; // Default path or a placeholder icon
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Props are defined in the component's signature
|
||||
const { icon, color = 'currentcolor', class: className = '', width = 80, height = 50 } = Astro.props as IconProps;
|
||||
const {
|
||||
icon,
|
||||
color = 'currentcolor',
|
||||
class: className = '',
|
||||
width = 80,
|
||||
height = 50,
|
||||
} = Astro.props as IconProps;
|
||||
const svgStyle = { fill: color };
|
||||
const { vb, path } = getSvgPath(icon);
|
||||
---
|
||||
|
||||
<svg class={className} style={svgStyle} xmlns="http://www.w3.org/2000/svg" viewBox={vb} width={width} height={height ?? width}>
|
||||
<path d={path} />
|
||||
<svg
|
||||
class={className}
|
||||
style={svgStyle}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={vb}
|
||||
width={width}
|
||||
height={height ?? width}
|
||||
>
|
||||
<path d={path}></path>
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
---
|
||||
|
||||
import Icon from '@components/form/FontAwesome.svelte';
|
||||
import { site, title as defaultTitle, description as defaultDescription } from '@utils/config';
|
||||
import {
|
||||
site,
|
||||
title as defaultTitle,
|
||||
description as defaultDescription,
|
||||
} from '@utils/config';
|
||||
|
||||
interface Props {
|
||||
url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const url = Astro.props.url || site;
|
||||
|
|
@ -18,109 +21,114 @@ const encodedTitle = encodeURIComponent(title);
|
|||
const encodedDescription = encodeURIComponent(description);
|
||||
|
||||
const socialMedias = {
|
||||
mastodon: {
|
||||
url: `https://mastodon.social/share?text=${encodeURIComponent(`${title} ${description}`)}&url=${encodedUrl}`,
|
||||
title: 'Mastodon',
|
||||
icon: 'mastodon',
|
||||
color: '#6364FF',
|
||||
},
|
||||
twitter: {
|
||||
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} ${description}`)}&url=${url}`,
|
||||
title: 'Twitter',
|
||||
icon: 'twitter',
|
||||
color: '#444343'
|
||||
},
|
||||
reddit: {
|
||||
url: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
|
||||
title: 'Reddit',
|
||||
icon: 'reddit',
|
||||
color: '#FF4500',
|
||||
},
|
||||
linkedIn: {
|
||||
url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}&summary=${encodedDescription}`,
|
||||
title: 'LinkedIn',
|
||||
icon: 'linkedin',
|
||||
color: '#0A66C2'
|
||||
},
|
||||
pinterest: {
|
||||
url: `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedTitle}`,
|
||||
title: 'Pinterest',
|
||||
icon: 'pinterest',
|
||||
color: '#BD081C',
|
||||
},
|
||||
telegram: {
|
||||
url: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent(`${title} ${description}`)}`,
|
||||
title: 'Telegram',
|
||||
icon: 'telegram',
|
||||
color: '#26A5E4',
|
||||
},
|
||||
whatsapp: {
|
||||
url: `https://wa.me/?text=${encodedTitle} ${encodedUrl}`,
|
||||
title: 'WhatsApp',
|
||||
icon: 'whatsapp',
|
||||
color: '#25D366',
|
||||
},
|
||||
signal: {
|
||||
url: `https://signal.me/#p/+${encodedUrl}`,
|
||||
title: 'Signal',
|
||||
icon: 'signal',
|
||||
color: '#3A76F0',
|
||||
},
|
||||
pocket: {
|
||||
url: `https://getpocket.com/save?url=${encodedUrl}&title=${encodedTitle}`,
|
||||
title: 'Pocket',
|
||||
icon: 'pocket',
|
||||
color: '#EF3F56',
|
||||
},
|
||||
mastodon: {
|
||||
url: `https://mastodon.social/share?text=${encodeURIComponent(`${title} ${description}`)}&url=${encodedUrl}`,
|
||||
title: 'Mastodon',
|
||||
icon: 'mastodon',
|
||||
color: '#6364FF',
|
||||
},
|
||||
twitter: {
|
||||
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} ${description}`)}&url=${url}`,
|
||||
title: 'Twitter',
|
||||
icon: 'twitter',
|
||||
color: '#444343',
|
||||
},
|
||||
reddit: {
|
||||
url: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
|
||||
title: 'Reddit',
|
||||
icon: 'reddit',
|
||||
color: '#FF4500',
|
||||
},
|
||||
linkedIn: {
|
||||
url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}&summary=${encodedDescription}`,
|
||||
title: 'LinkedIn',
|
||||
icon: 'linkedin',
|
||||
color: '#0A66C2',
|
||||
},
|
||||
pinterest: {
|
||||
url: `https://pinterest.com/pin/create/button/?url=${encodedUrl}&description=${encodedTitle}`,
|
||||
title: 'Pinterest',
|
||||
icon: 'pinterest',
|
||||
color: '#BD081C',
|
||||
},
|
||||
telegram: {
|
||||
url: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent(`${title} ${description}`)}`,
|
||||
title: 'Telegram',
|
||||
icon: 'telegram',
|
||||
color: '#26A5E4',
|
||||
},
|
||||
whatsapp: {
|
||||
url: `https://wa.me/?text=${encodedTitle} ${encodedUrl}`,
|
||||
title: 'WhatsApp',
|
||||
icon: 'whatsapp',
|
||||
color: '#25D366',
|
||||
},
|
||||
signal: {
|
||||
url: `https://signal.me/#p/+${encodedUrl}`,
|
||||
title: 'Signal',
|
||||
icon: 'signal',
|
||||
color: '#3A76F0',
|
||||
},
|
||||
pocket: {
|
||||
url: `https://getpocket.com/save?url=${encodedUrl}&title=${encodedTitle}`,
|
||||
title: 'Pocket',
|
||||
icon: 'pocket',
|
||||
color: '#EF3F56',
|
||||
},
|
||||
};
|
||||
|
||||
---
|
||||
|
||||
<ul class="social-share">
|
||||
{Object.entries(socialMedias).map(([platform, shareUrl]) => (
|
||||
<li style={`--color: ${shareUrl.color}`}>
|
||||
<a title={`Share on ${platform}`} href={shareUrl.url} target="_blank" rel="noopener noreferrer">
|
||||
<Icon iconName={shareUrl.icon} />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{
|
||||
Object.entries(socialMedias).map(([platform, shareUrl]) => (
|
||||
<li style={`--color: ${shareUrl.color}`}>
|
||||
<a
|
||||
title={`Share on ${platform}`}
|
||||
href={shareUrl.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Icon iconName={shareUrl.icon} />
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
.social-share {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
li {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
border: 1px solid var(--box-outline);
|
||||
box-shadow: 2px 2px 0 var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
transition: all 0.2s ease-in-out;
|
||||
background: var(--background-form);
|
||||
a {
|
||||
display: flex;
|
||||
color: var(--foreground);
|
||||
transition: all 0.2s ease-in-out;
|
||||
padding: 4px;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
border-radius: var(--curve-md);
|
||||
opacity: 1;
|
||||
a {
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.social-share {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
li {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
border: 1px solid var(--box-outline);
|
||||
box-shadow: 2px 2px 0 var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
transition: all 0.2s ease-in-out;
|
||||
background: var(--background-form);
|
||||
a {
|
||||
display: flex;
|
||||
color: var(--foreground);
|
||||
transition: all 0.2s ease-in-out;
|
||||
padding: 4px;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
border-radius: var(--curve-md);
|
||||
opacity: 1;
|
||||
a {
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="theme-switcher" on:click={toggleTheme}>
|
||||
|
|
@ -38,7 +37,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.theme-switcher {
|
||||
cursor: pointer;
|
||||
|
|
@ -52,7 +50,7 @@
|
|||
transition: background-color 0.3s ease;
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
|
@ -96,5 +94,4 @@
|
|||
display: flex;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,29 @@
|
|||
---
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer>
|
||||
<a href="/about">Awesome Privacy</a> is licensed
|
||||
under <a href="https://github.com/Lissy93/awesome-privacy/blob/main/LICENSE">MIT</a>
|
||||
© <a href="https://aliciasykes.com">Alicia Sykes</a> 2024 |
|
||||
Source code available on <a href="https://github.com/Lissy93/awesome-privacy">GitHub</a>
|
||||
<a href="/about">Awesome Privacy</a> is licensed under <a
|
||||
href="https://github.com/Lissy93/awesome-privacy/blob/main/LICENSE"
|
||||
>CC0 1.0 Universal</a
|
||||
>
|
||||
© <a href="https://aliciasykes.com">Alicia Sykes</a> 2018 - {
|
||||
year || 'Present'
|
||||
} | Source code available on <a
|
||||
href="https://github.com/Lissy93/awesome-privacy">GitHub</a
|
||||
>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
footer {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
margin: 0 auto;
|
||||
a {
|
||||
font-family: "Lekton", sans-serif;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
footer {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
margin: 0 auto;
|
||||
a {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -11,8 +9,8 @@
|
|||
width: 1200px;
|
||||
max-width: calc(100% - 5rem);
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,106 +1,111 @@
|
|||
---
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte"
|
||||
import ThemeSwitcher from "@components/form/ThemeSwitcher.svelte"
|
||||
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
import ThemeSwitcher from '@components/form/ThemeSwitcher.svelte';
|
||||
---
|
||||
|
||||
<div class="nav">
|
||||
<a href="/" class="homepage">
|
||||
<FontAwesome iconName="logo" />
|
||||
<h1>Awesome Privacy</h1>
|
||||
</a>
|
||||
<nav>
|
||||
<a href="/browse">Browse</a>
|
||||
<a href="/search">Search</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="https://github.com/lissy93/awesome-privacy">GitHub</a>
|
||||
<div class="theme-switcher">
|
||||
<ThemeSwitcher client:load />
|
||||
</div>
|
||||
</nav>
|
||||
<a href="/" class="homepage">
|
||||
<FontAwesome iconName="logo" />
|
||||
<h1>Awesome Privacy</h1>
|
||||
</a>
|
||||
<nav>
|
||||
<a href="/browse">Browse</a>
|
||||
<a href="/search">Search</a>
|
||||
<a href="/about">About</a>
|
||||
<a href="https://github.com/lissy93/awesome-privacy">GitHub</a>
|
||||
<div class="theme-switcher">
|
||||
<ThemeSwitcher client:load />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.nav {
|
||||
background: var(--accent-fg);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
border-bottom: 2px solid var(--accent-3);
|
||||
|
||||
.homepage {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2.4rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--foreground);
|
||||
font-family: "Lekton", sans-serif;
|
||||
}
|
||||
:global(svg) {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--accent-3);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
&:hover {
|
||||
:global(svg) {
|
||||
color: var(--accent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
.nav {
|
||||
background: var(--accent-fg);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
border-bottom: 2px solid var(--accent-3);
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
a {
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
color: var(--foreground);
|
||||
transition: background 0.3s, transform 0.3s, box-shadow 0.3s;
|
||||
transition-timing-function: ease-in-out;
|
||||
&:hover {
|
||||
background: var(--accent-3);
|
||||
color: var(--accent-fg);
|
||||
border-bottom: 2px solid var(--accent-3);
|
||||
&:nth-child(4n+1) {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
&:nth-child(4n+2) {
|
||||
background-color: var(--accent-2);
|
||||
color: var(--foreground);
|
||||
}
|
||||
&:nth-child(4n+3) {
|
||||
background-color: var(--accent-3);
|
||||
}
|
||||
&:nth-child(4n+4) {
|
||||
background-color: var(--accent-4);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-switcher {
|
||||
transform: scale(0.7);
|
||||
margin: 0.2rem auto;
|
||||
@media(max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.homepage, nav {
|
||||
@media(max-width: 768px) {
|
||||
margin: 0 auto;
|
||||
a { border: none; }
|
||||
.homepage {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2.4rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--foreground);
|
||||
font-family: 'Lekton', sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
||||
:global(svg) {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--accent-3);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
&:hover {
|
||||
:global(svg) {
|
||||
color: var(--accent);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
a {
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
color: var(--foreground);
|
||||
transition:
|
||||
background 0.3s,
|
||||
transform 0.3s,
|
||||
box-shadow 0.3s;
|
||||
transition-timing-function: ease-in-out;
|
||||
&:hover {
|
||||
background: var(--accent-3);
|
||||
color: var(--accent-fg);
|
||||
border-bottom: 2px solid var(--accent-3);
|
||||
&:nth-child(4n + 1) {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
&:nth-child(4n + 2) {
|
||||
background-color: var(--accent-2);
|
||||
color: var(--foreground);
|
||||
}
|
||||
&:nth-child(4n + 3) {
|
||||
background-color: var(--accent-3);
|
||||
}
|
||||
&:nth-child(4n + 4) {
|
||||
background-color: var(--accent-4);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
.theme-switcher {
|
||||
transform: scale(0.7);
|
||||
margin: 0.2rem auto;
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.homepage,
|
||||
nav {
|
||||
@media (max-width: 768px) {
|
||||
margin: 0 auto;
|
||||
a {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,33 +20,42 @@
|
|||
const serviceCrypto = writable(false);
|
||||
const additionalInfo = writable('');
|
||||
|
||||
let codeBlock: any;
|
||||
let codeBlock: HTMLElement | undefined;
|
||||
let interactiveActivated = false;
|
||||
|
||||
$: yamlText, updateHighlighting();
|
||||
$: (yamlText, updateHighlighting());
|
||||
|
||||
/* eslint-disable svelte/no-dom-manipulating -- hljs requires direct DOM access for syntax highlighting */
|
||||
function updateHighlighting() {
|
||||
if (codeBlock) {
|
||||
codeBlock.textContent = yamlText
|
||||
codeBlock.textContent = yamlText;
|
||||
codeBlock.dataset.highlighted && delete codeBlock.dataset.highlighted;
|
||||
if (window && (window as any).hljs) {
|
||||
(window as any).hljs.highlightElement(codeBlock);
|
||||
const hljs = (
|
||||
window as Window & {
|
||||
hljs?: { highlightElement: (el: HTMLElement) => void };
|
||||
}
|
||||
).hljs;
|
||||
if (hljs) {
|
||||
hljs.highlightElement(codeBlock);
|
||||
interactiveActivated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable svelte/no-dom-manipulating */
|
||||
|
||||
const filterEmptyValues = (obj: Record<string, any>) => {
|
||||
const filteredObj: Record<string, any> = {};
|
||||
Object.keys(obj).forEach(key => {
|
||||
const filterEmptyValues = (obj: Record<string, unknown>) => {
|
||||
const filteredObj: Record<string, unknown> = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
if (obj[key] || ['name', 'url', 'icon', 'description'].includes(key)) {
|
||||
filteredObj[key] = obj[key];
|
||||
}
|
||||
});
|
||||
return filteredObj;
|
||||
}
|
||||
|
||||
$: yamlText = yaml.dump([{
|
||||
};
|
||||
|
||||
$: yamlText = yaml.dump(
|
||||
[
|
||||
{
|
||||
name: $serviceName,
|
||||
url: $serviceUrl,
|
||||
icon: $serviceIcon,
|
||||
|
|
@ -60,9 +69,12 @@
|
|||
openSource: $serviceOpenSource,
|
||||
securityAudited: $serviceSecurityAudited,
|
||||
acceptsCrypto: $serviceCrypto,
|
||||
}].map(obj => filterEmptyValues(obj)));
|
||||
},
|
||||
].map((obj) => filterEmptyValues(obj)),
|
||||
);
|
||||
|
||||
$: issueUrl = makeAdditionRequest({
|
||||
$: issueUrl = makeAdditionRequest(
|
||||
{
|
||||
listingCategory: $listingCategory,
|
||||
serviceName: $serviceName,
|
||||
serviceUrl: $serviceUrl,
|
||||
|
|
@ -78,7 +90,9 @@
|
|||
serviceSecurityAudited: $serviceSecurityAudited,
|
||||
serviceCrypto: $serviceCrypto,
|
||||
additionalInfo: $additionalInfo,
|
||||
}, yamlText);
|
||||
},
|
||||
yamlText,
|
||||
);
|
||||
|
||||
// Form submission handler
|
||||
function handleSubmit() {
|
||||
|
|
@ -87,29 +101,39 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/an-old-hope.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/an-old-hope.min.css"
|
||||
/>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
|
||||
></script>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/yaml.min.js"
|
||||
></script>
|
||||
</svelte:head>
|
||||
|
||||
<p>
|
||||
Before completing this form, you must ensure that the service you are adding aligns
|
||||
with the <a href="/about#creteria">Requirements</a> for Awesome Privacy.
|
||||
Before completing this form, you must ensure that the service you are adding
|
||||
aligns with the <a href="/about#creteria">Requirements</a> for Awesome
|
||||
Privacy.
|
||||
<br />
|
||||
You'll need a GitHub account in order to submit this form.
|
||||
</p>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
|
||||
<h3>Basics</h3>
|
||||
<p class="sub-title-description">
|
||||
All fields here are required.
|
||||
</p>
|
||||
<p class="sub-title-description">All fields here are required.</p>
|
||||
|
||||
<!-- Category Dropdown -->
|
||||
<div class="form-row">
|
||||
<label for="listing-category">Category</label>
|
||||
<select bind:value={$listingCategory} id="listing-category" required autocomplete="off">
|
||||
<select
|
||||
bind:value={$listingCategory}
|
||||
id="listing-category"
|
||||
required
|
||||
autocomplete="off"
|
||||
>
|
||||
<option value="">--Please choose an option--</option>
|
||||
<option value="Essentials">Essentials</option>
|
||||
<option value="Communication">Communication</option>
|
||||
|
|
@ -126,45 +150,78 @@
|
|||
<option value="Creativity">Creativity</option>
|
||||
</select>
|
||||
<p>
|
||||
Choose the top-level category, which should align with
|
||||
the <a href="/browse">one of these</a>.
|
||||
Choose the top-level category, which should align with the <a
|
||||
href="/browse">one of these</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Listing Name -->
|
||||
<div class="form-row">
|
||||
<label for="service-name">Listing Name</label>
|
||||
<input type="text" bind:value={$serviceName} id="service-name" required autocomplete="off">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={$serviceName}
|
||||
id="service-name"
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>Enter the name of the app, software or service</p>
|
||||
</div>
|
||||
|
||||
<!-- Listing URL -->
|
||||
<div class="form-row">
|
||||
<label for="service-url">Listing URL</label>
|
||||
<input type="url" bind:value={$serviceUrl} id="service-url" required autocomplete="off">
|
||||
<p>Enter the fully-qualified domain name of the homepage for this listing</p>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={$serviceUrl}
|
||||
id="service-url"
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
Enter the fully-qualified domain name of the homepage for this listing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Listing Icon -->
|
||||
<div class="form-row">
|
||||
<label for="service-icon">Listing Icon</label>
|
||||
<input type="url" bind:value={$serviceIcon} id="service-icon" required autocomplete="off">
|
||||
<p>Paste a URL to a square logo for the service. Dimensions must be no less than 64x64, and no more than 512x512 pixels</p>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={$serviceIcon}
|
||||
id="service-icon"
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
Paste a URL to a square logo for the service. Dimensions must be no less
|
||||
than 64x64, and no more than 512x512 pixels
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Listing Description -->
|
||||
<div class="form-row">
|
||||
<label for="service-description">Listing Description</label>
|
||||
<textarea bind:value={$serviceDescription} id="service-description" required autocomplete="off"></textarea>
|
||||
<p>Please provide a description for this listing. Keep it factual and objective. Markdown is supported.</p>
|
||||
<textarea
|
||||
bind:value={$serviceDescription}
|
||||
id="service-description"
|
||||
required
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<p>
|
||||
Please provide a description for this listing. Keep it factual and
|
||||
objective. Markdown is supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 2 -->
|
||||
<h3>Third-Party Referencing</h3>
|
||||
<p class="sub-title-description">
|
||||
In order to create a comprehensive listing, we combine the data inputted above with other sources,
|
||||
to give additional context and help users make informed decisions.
|
||||
Metrics from these services are fetched automatically at build-time from our API.
|
||||
In order to create a comprehensive listing, we combine the data inputted
|
||||
above with other sources, to give additional context and help users make
|
||||
informed decisions. Metrics from these services are fetched automatically at
|
||||
build-time from our API.
|
||||
<br />
|
||||
All fields are optional, but the more information you provide, the better!
|
||||
</p>
|
||||
|
|
@ -172,7 +229,13 @@
|
|||
<!-- GitHub Repository -->
|
||||
<div class="form-row">
|
||||
<label for="service-github">GitHub Repository</label>
|
||||
<input type="text" bind:value={$serviceGithub} id="service-github" required autocomplete="off">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={$serviceGithub}
|
||||
id="service-github"
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
Share a link to where the project's source is located.<br />
|
||||
Use the format [user]/[repo] e.g, lissy93/dashy
|
||||
|
|
@ -182,18 +245,29 @@
|
|||
<!-- ToS;DR ID -->
|
||||
<div class="form-row">
|
||||
<label for="service-tosdr-id">ToS;DR ID</label>
|
||||
<input type="number" bind:value={$serviceTosdrId} id="service-tosdr-id" autocomplete="off">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={$serviceTosdrId}
|
||||
id="service-tosdr-id"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
Has the Privacy policy been documented by <a href="https://tosdr.org/">tosdr.org</a>?
|
||||
If so, please include the report reference below (this is a 3 or 4-digit numerical ID).
|
||||
Skip section if not applicable.
|
||||
Has the Privacy policy been documented by <a href="https://tosdr.org/"
|
||||
>tosdr.org</a
|
||||
>? If so, please include the report reference below (this is a 3 or
|
||||
4-digit numerical ID). Skip section if not applicable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Apple App Store URL -->
|
||||
<div class="form-row">
|
||||
<label for="service-tosdr-id">iOS App</label>
|
||||
<input type="url" bind:value={$serviceIosApp} id="service-ios-app" autocomplete="off">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={$serviceIosApp}
|
||||
id="service-ios-app"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
Paste the link to the mobile app on the Apple App Store.<br />
|
||||
E.g. https://apps.apple.com/us/app/bitwarden-password-manager/id1137397744
|
||||
|
|
@ -203,7 +277,12 @@
|
|||
<!-- Google Play App Store URL -->
|
||||
<div class="form-row">
|
||||
<label for="service-tosdr-id">Android App</label>
|
||||
<input type="url" bind:value={$serviceAndroidApp} id="service-android-app" autocomplete="off">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={$serviceAndroidApp}
|
||||
id="service-android-app"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
Paste the link to the mobile app on the Google Play Store.<br />
|
||||
E.g. https://play.google.com/store/apps/details?id=com.x8bit.bitwarden
|
||||
|
|
@ -213,17 +292,28 @@
|
|||
<!-- Discord Server Invite Code -->
|
||||
<div class="form-row">
|
||||
<label for="service-tosdr-id">Discord Invite</label>
|
||||
<input type="text" bind:value={$serviceDiscordInvite} id="service-discord-invite" autocomplete="off">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={$serviceDiscordInvite}
|
||||
id="service-discord-invite"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
Paste the invite code to the Discord server for this service.<br />
|
||||
E.g. If the invite URL is https://discord.com/invite/4JMAauFZBq the code is 4JMAauFZBq
|
||||
E.g. If the invite URL is https://discord.com/invite/4JMAauFZBq the code is
|
||||
4JMAauFZBq
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reddit sub name -->
|
||||
<div class="form-row">
|
||||
<label for="service-tosdr-id">Subreddit</label>
|
||||
<input type="text" bind:value={$serviceSubreddit} id="service-subreddit" autocomplete="off">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={$serviceSubreddit}
|
||||
id="service-subreddit"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p>
|
||||
If the service has a subreddit, please provide the name here.<br />
|
||||
Don't include `r/` in the name, nor the full URL - just the sub name.
|
||||
|
|
@ -233,70 +323,95 @@
|
|||
<!-- Section 3 - Checklist and details -->
|
||||
<h3>Privacy Checklist</h3>
|
||||
<p class="sub-title-description">
|
||||
Finally, check the boxes that apply to the service you are submitting,
|
||||
and then provide any additional information to back this up in the text area below.
|
||||
Finally, check the boxes that apply to the service you are submitting, and
|
||||
then provide any additional information to back this up in the text area
|
||||
below.
|
||||
</p>
|
||||
|
||||
<!-- Open Source Checkbox -->
|
||||
<!-- Open Source Checkbox -->
|
||||
<div class="form-row">
|
||||
<label for="service-open-source">Is Open Source?</label>
|
||||
<input type="checkbox" bind:checked={$serviceOpenSource} id="service-open-source">
|
||||
<p>Is this service fully open source? Aka, can it be compiled from source by the user, or self-hosted?</p>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$serviceOpenSource}
|
||||
id="service-open-source"
|
||||
/>
|
||||
<p>
|
||||
Is this service fully open source? Aka, can it be compiled from source by
|
||||
the user, or self-hosted?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Security Audited Checkbox -->
|
||||
<div class="form-row">
|
||||
<label for="service-security-audited">Security Audited?</label>
|
||||
<input type="checkbox" bind:checked={$serviceSecurityAudited} id="service-security-audited">
|
||||
<p>Has this service been independently security audited by an accredited auditor?</p>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$serviceSecurityAudited}
|
||||
id="service-security-audited"
|
||||
/>
|
||||
<p>
|
||||
Has this service been independently security audited by an accredited
|
||||
auditor?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Accepts Crypto Checkbox -->
|
||||
<div class="form-row">
|
||||
<label for="service-crypto">Accepts Anon Payment?</label>
|
||||
<input type="checkbox" bind:checked={$serviceCrypto} id="service-crypto">
|
||||
<p>If this is a hosted and paid for service, does it accept anonymous payment methods, including crypto (e.g., Monero)?</p>
|
||||
<input type="checkbox" bind:checked={$serviceCrypto} id="service-crypto" />
|
||||
<p>
|
||||
If this is a hosted and paid for service, does it accept anonymous payment
|
||||
methods, including crypto (e.g., Monero)?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="final-info">
|
||||
<p>
|
||||
Finally, please provide any supporting material, including:
|
||||
</p>
|
||||
<p>Finally, please provide any supporting material, including:</p>
|
||||
<ul>
|
||||
<li>
|
||||
A justification of why this app/service should be included in the list
|
||||
</li>
|
||||
<li>Links to any published security audit, if they exist</li>
|
||||
<li>
|
||||
Links to any published security audit, if they exist
|
||||
Links to the services privacy policy, terms of service and other
|
||||
relevant documents where applicable
|
||||
</li>
|
||||
<li>
|
||||
Links to the services privacy policy, terms of service and other relevant
|
||||
documents where applicable
|
||||
Your affiliation with the service. For transparency, you must disclose
|
||||
if you are associated with them or any similar items in any way
|
||||
</li>
|
||||
<li>
|
||||
Your affiliation with the service.
|
||||
For transparency, you must disclose if you are associated
|
||||
with them or any similar items in any way
|
||||
Links to relevant discussions, past issues/PRs related to this service
|
||||
</li>
|
||||
<li>Links to relevant discussions, past issues/PRs related to this service</li>
|
||||
</ul>
|
||||
<textarea bind:value={$additionalInfo} id="additional-info" rows="5"></textarea>
|
||||
<textarea bind:value={$additionalInfo} id="additional-info" rows="5"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
<a href={issueUrl} target="_blank" class="open-in-gh">Open in GitHub Issues</a>
|
||||
<a href={issueUrl} target="_blank" class="open-in-gh">Open in GitHub Issues</a
|
||||
>
|
||||
</form>
|
||||
|
||||
<div class="output-yaml">
|
||||
<p>Below is the YAML content, which will be appended to the appropriate section
|
||||
within <a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a>
|
||||
<p>
|
||||
Below is the YAML content, which will be appended to the appropriate section
|
||||
within <a
|
||||
href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
|
||||
>awesome-privacy.yml</a
|
||||
>
|
||||
upon approval.
|
||||
</p>
|
||||
{#if !interactiveActivated || !codeBlock}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- yamlText is generated from user form input via yaml.dump, not arbitrary HTML -->
|
||||
<pre><code class="language-yaml">{@html yamlText}</code></pre>
|
||||
{/if}
|
||||
<pre><code bind:this={codeBlock} class="language-yaml"></code></pre>
|
||||
<p>Your submission will need to be reviewed by a maintainer and the community before it can be merged.</p>
|
||||
<p>
|
||||
Your submission will need to be reviewed by a maintainer and the community
|
||||
before it can be merged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -332,7 +447,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--accent-3);
|
||||
border-radius: var(--curve-md);
|
||||
|
|
@ -347,15 +464,15 @@
|
|||
}
|
||||
input {
|
||||
height: fit-content;
|
||||
&[type="number"]::-webkit-outer-spin-button,
|
||||
&[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
&[type='number']::-webkit-outer-spin-button,
|
||||
&[type='number']::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
&[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
&[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
&[type="checkbox"] {
|
||||
&[type='checkbox'] {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: var(--background-form);
|
||||
|
|
@ -381,7 +498,7 @@
|
|||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
border-radius: var(--curve-lg);
|
||||
font-size: 1.8rem;
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
|
|
|||
|
|
@ -1,170 +1,178 @@
|
|||
---
|
||||
|
||||
import type { AndroidInfo } from '@utils/fetch-android-info';
|
||||
import { formatDate, timeAgo } from '@utils/dates-n-stuff';
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte"
|
||||
|
||||
import { formatDate } from '@utils/dates-n-stuff';
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
|
||||
interface Props {
|
||||
androidData: AndroidInfo;
|
||||
};
|
||||
androidData: AndroidInfo;
|
||||
}
|
||||
|
||||
const { androidData } = Astro.props;
|
||||
|
||||
function permissionToReadable(permission: string): string {
|
||||
return (permission
|
||||
.split('.')
|
||||
.pop() || '')
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
return (permission.split('.').pop() || '')
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
|
||||
---
|
||||
|
||||
<div class="android-info-wrapper">
|
||||
<div class="left">
|
||||
<h4>Update Info</h4>
|
||||
<ul class="list-table">
|
||||
<li>
|
||||
<span class="lbl">App</span>
|
||||
<span class="val">
|
||||
<!-- <img width="20" src={`https://reports.exodus-privacy.eu.org/en/reports/${androidData.version_code}/icon`} alt="Android Icon" /> -->
|
||||
<a href={`https://play.google.com/store/apps/details?id=${androidData.handle}`}>{androidData.app_name}</a>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Creation Date</span>
|
||||
<span class="val">{formatDate(androidData.created)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Last Updated</span>
|
||||
<span class="val">{formatDate(androidData.updated)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Current Version</span>
|
||||
<span class="val">{androidData.version_name}</span>
|
||||
</li>
|
||||
{androidData.creator && (
|
||||
<li>
|
||||
<span class="lbl">Creator</span>
|
||||
<span class="val">{androidData.creator}</span>
|
||||
</li>
|
||||
)}
|
||||
{androidData.downloads && (
|
||||
<li>
|
||||
<span class="lbl">Downloads</span>
|
||||
<span class="val">{androidData.downloads}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div class="left">
|
||||
<h4>Update Info</h4>
|
||||
<ul class="list-table">
|
||||
<li>
|
||||
<span class="lbl">App</span>
|
||||
<span class="val">
|
||||
<!-- <img width="20" src={`https://reports.exodus-privacy.eu.org/en/reports/${androidData.version_code}/icon`} alt="Android Icon" /> -->
|
||||
<a
|
||||
href={`https://play.google.com/store/apps/details?id=${androidData.handle}`}
|
||||
>{androidData.app_name}</a
|
||||
>
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Creation Date</span>
|
||||
<span class="val">{formatDate(androidData.created)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Last Updated</span>
|
||||
<span class="val">{formatDate(androidData.updated)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Current Version</span>
|
||||
<span class="val">{androidData.version_name}</span>
|
||||
</li>
|
||||
{
|
||||
androidData.creator && (
|
||||
<li>
|
||||
<span class="lbl">Creator</span>
|
||||
<span class="val">{androidData.creator}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
{
|
||||
androidData.downloads && (
|
||||
<li>
|
||||
<span class="lbl">Downloads</span>
|
||||
<span class="val">{androidData.downloads}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
|
||||
<h4>Trackers</h4>
|
||||
{(androidData.trackers || []).length === 0 && (
|
||||
<p class="all-good">
|
||||
<FontAwesome iconName="noTrackers" />
|
||||
No trackers found
|
||||
</p>
|
||||
)}
|
||||
<ul class="list">
|
||||
{(androidData.trackers || []).map((track) => (
|
||||
<li title={track.code_signature}>{track.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<h4>Trackers</h4>
|
||||
{
|
||||
(androidData.trackers || []).length === 0 && (
|
||||
<p class="all-good">
|
||||
<FontAwesome iconName="noTrackers" />
|
||||
No trackers found
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<ul class="list">
|
||||
{
|
||||
(androidData.trackers || []).map((track) => (
|
||||
<li title={track.code_signature}>{track.name}</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<h4>Permissions</h4>
|
||||
{(androidData.permissions || []).length === 0 && (
|
||||
<p class="all-good">
|
||||
<FontAwesome iconName="noTrackers" />
|
||||
No permissions required
|
||||
</p>
|
||||
)}
|
||||
<ul class="list">
|
||||
{(androidData.permissions || []).map((perm) => (
|
||||
<li title={perm}>{permissionToReadable(perm)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h4>Permissions</h4>
|
||||
{
|
||||
(androidData.permissions || []).length === 0 && (
|
||||
<p class="all-good">
|
||||
<FontAwesome iconName="noTrackers" />
|
||||
No permissions required
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<ul class="list">
|
||||
{
|
||||
(androidData.permissions || []).map((perm) => (
|
||||
<li title={perm}>{permissionToReadable(perm)}</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.android-info-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
.left, .right {
|
||||
width: calc(50% - 1rem);
|
||||
@media screen and (max-width: 768px){
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.android-info-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
.left,
|
||||
.right {
|
||||
width: calc(50% - 1rem);
|
||||
@media screen and (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
&.list-table {
|
||||
font-size: 0.9rem;
|
||||
padding-left: 0;
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.1rem 0;
|
||||
.lbl {
|
||||
font-weight: 400;
|
||||
}
|
||||
.val {
|
||||
img {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #5f53f440;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.list {
|
||||
list-style: circle;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.all-good {
|
||||
color: var(--success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
:global(svg) {
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
&.list-table {
|
||||
font-size: 0.9rem;
|
||||
padding-left: 0;
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.1rem 0;
|
||||
.lbl {
|
||||
font-weight: 400;
|
||||
}
|
||||
.val {
|
||||
img {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #5f53f440;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.list {
|
||||
list-style: circle;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.all-good {
|
||||
color: var(--success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
:global(svg) {
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
<svelte:head>
|
||||
<script async lang="javascript">
|
||||
var remark_config = {
|
||||
host: 'https://comments.as93.net', site_id: 'awesome-privacy.xyz',
|
||||
components: ['embed'], show_rss_subsription: true, theme: 'dark',
|
||||
};
|
||||
!(function (e, n) {
|
||||
for (var o = 0; o < e.length; o++) {
|
||||
var r = n.createElement('script'), d = n.head || n.body;
|
||||
'noModule' in r ?
|
||||
(r.type = 'module', r.src = remark_config.host + '/web/' + e[o] + '.mjs')
|
||||
: ( r.async = !0, r.defer = !0, r.src = remark_config.host + '/web/' + e[o] + '.js'),
|
||||
d.appendChild(r);
|
||||
}
|
||||
})(remark_config.components || ['embed'], document);
|
||||
|
||||
var remark_config = {
|
||||
host: 'https://comments.as93.net',
|
||||
site_id: 'awesome-privacy.xyz',
|
||||
components: ['embed'],
|
||||
show_rss_subsription: true,
|
||||
theme: 'dark',
|
||||
};
|
||||
!(function (e, n) {
|
||||
for (var o = 0; o < e.length; o++) {
|
||||
var r = n.createElement('script'),
|
||||
d = n.head || n.body;
|
||||
('noModule' in r
|
||||
? ((r.type = 'module'),
|
||||
(r.src = remark_config.host + '/web/' + e[o] + '.mjs'))
|
||||
: ((r.async = !0),
|
||||
(r.defer = !0),
|
||||
(r.src = remark_config.host + '/web/' + e[o] + '.js')),
|
||||
d.appendChild(r));
|
||||
}
|
||||
})(remark_config.components || ['embed'], document);
|
||||
</script>
|
||||
</svelte:head>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fetchSrcData, makeRemovalRequest, makeEditRequest } from '@utils/data-src-delete-n-edit';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
fetchSrcData,
|
||||
makeRemovalRequest,
|
||||
makeEditRequest,
|
||||
} from '@utils/data-src-delete-n-edit';
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
|
||||
export let categoryName: string;
|
||||
export let sectionName: string;
|
||||
export let serviceName: string;
|
||||
|
||||
let lineNumbers: { start: number, end: number } | null = null;
|
||||
let lineNumbers: { start: number; end: number } | null = null;
|
||||
let yamlContent = '';
|
||||
|
||||
const getGitHubSrcFile = () => {
|
||||
if (lineNumbers) {
|
||||
const baseFile = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
const baseFile =
|
||||
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
return `${baseFile}#L${lineNumbers.start}-L${lineNumbers.end}`;
|
||||
}
|
||||
return '';
|
||||
|
|
@ -21,56 +26,68 @@
|
|||
const getIframeSrc = () => {
|
||||
const host = 'https://github-embed.as93.net';
|
||||
const target = encodeURIComponent(getGitHubSrcFile());
|
||||
const opts = 'style=felipec&type=code&showBorder=on&showLineNumbers=on&showFileMeta=on&showFullPath=on&showCopy=on';
|
||||
const opts =
|
||||
'style=felipec&type=code&showBorder=on&showLineNumbers=on&showFileMeta=on&showFullPath=on&showCopy=on';
|
||||
return `${host}/iframe.html?target=${target}&${opts}`;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const results = await fetchSrcData(categoryName, sectionName, serviceName);
|
||||
lineNumbers = results.lineNumbers
|
||||
lineNumbers = results.lineNumbers;
|
||||
yamlContent = results.yamlContent;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{#if lineNumbers}
|
||||
<h4>Edit {serviceName} Data</h4>
|
||||
<p>
|
||||
You can view or edit this {serviceName}'s entry in
|
||||
<a href={getGitHubSrcFile()}> this section </a>
|
||||
of <code>awesome-privacy.yml</code> in our GitHub repo.
|
||||
</p>
|
||||
|
||||
<h4>Edit {serviceName} Data</h4>
|
||||
<p>
|
||||
You can view or edit this {serviceName}'s entry in
|
||||
<a href={getGitHubSrcFile()}>
|
||||
this section
|
||||
</a>
|
||||
of <code>awesome-privacy.yml</code> in our GitHub repo.
|
||||
</p>
|
||||
|
||||
<h4>Origin Data</h4>
|
||||
<iframe
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
class="yaml-embed"
|
||||
allow="clipboard-write"
|
||||
title="awesome-privacy.yml"
|
||||
src={getIframeSrc()}></iframe>
|
||||
|
||||
<h4>Modify Data</h4>
|
||||
<div class="button-wrap">
|
||||
<a class="button-link" target="_blank"
|
||||
href={makeRemovalRequest(categoryName, sectionName, serviceName, yamlContent)}>
|
||||
<FontAwesome iconName="delete" /> Delete {serviceName}
|
||||
</a>
|
||||
<a class="button-link" target="_blank"
|
||||
href={makeEditRequest(categoryName, sectionName, serviceName, yamlContent)}>
|
||||
<FontAwesome iconName="edit" /> Submit Edit to {serviceName}
|
||||
</a>
|
||||
<a class="button-link" href="/submit">
|
||||
<FontAwesome iconName="add" /> Add alternative
|
||||
</a>
|
||||
</div>
|
||||
<h4>Origin Data</h4>
|
||||
<iframe
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
class="yaml-embed"
|
||||
allow="clipboard-write"
|
||||
title="awesome-privacy.yml"
|
||||
src={getIframeSrc()}
|
||||
></iframe>
|
||||
|
||||
<h4>Modify Data</h4>
|
||||
<div class="button-wrap">
|
||||
<a
|
||||
class="button-link"
|
||||
target="_blank"
|
||||
href={makeRemovalRequest(
|
||||
categoryName,
|
||||
sectionName,
|
||||
serviceName,
|
||||
yamlContent,
|
||||
)}
|
||||
>
|
||||
<FontAwesome iconName="delete" /> Delete {serviceName}
|
||||
</a>
|
||||
<a
|
||||
class="button-link"
|
||||
target="_blank"
|
||||
href={makeEditRequest(
|
||||
categoryName,
|
||||
sectionName,
|
||||
serviceName,
|
||||
yamlContent,
|
||||
)}
|
||||
>
|
||||
<FontAwesome iconName="edit" /> Submit Edit to {serviceName}
|
||||
</a>
|
||||
<a class="button-link" href="/submit">
|
||||
<FontAwesome iconName="add" /> Add alternative
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
h4 {
|
||||
font-size: 1.4rem;
|
||||
|
|
@ -85,7 +102,7 @@
|
|||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin: 1rem auto;
|
||||
@media(max-width: 768px) {
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
@ -101,7 +118,7 @@
|
|||
min-width: 15rem;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-family: "Lekton",sans-serif;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
:global(svg) {
|
||||
width: 1rem;
|
||||
|
|
@ -118,5 +135,4 @@
|
|||
margin: 1rem auto;
|
||||
box-shadow: 3px 3px 0 var(--accent-3);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
|
||||
<script lang="ts">
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
import { fetchSrcData, makeRemovalRequest } from '@utils/data-src-delete-n-edit';
|
||||
import {
|
||||
fetchSrcData,
|
||||
makeRemovalRequest,
|
||||
} from '@utils/data-src-delete-n-edit';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
|
||||
export let categoryName: string;
|
||||
export let sectionName: string;
|
||||
export let serviceName: string;
|
||||
|
||||
const apYaml = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
const apYaml =
|
||||
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
|
||||
let yamlContent = '';
|
||||
let editLink = apYaml;
|
||||
|
|
@ -19,43 +21,51 @@
|
|||
yamlContent = results.yamlContent;
|
||||
|
||||
const lineNumbers = results.lineNumbers || null;
|
||||
const numberRange = lineNumbers ? `#L${lineNumbers.start}-L${lineNumbers.end}` : '';
|
||||
const yamlLink = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
const numberRange = lineNumbers
|
||||
? `#L${lineNumbers.start}-L${lineNumbers.end}`
|
||||
: '';
|
||||
const yamlLink =
|
||||
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
editLink = `${yamlLink}${numberRange}`;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<div class="actions">
|
||||
<a title="Edit" target="_blank"
|
||||
href={editLink}>
|
||||
<a title="Edit" target="_blank" href={editLink}>
|
||||
<FontAwesome iconName="edit" />
|
||||
</a>
|
||||
<a title="Delete" target="_blank"
|
||||
href={makeRemovalRequest(categoryName, sectionName, serviceName, yamlContent)}>
|
||||
<a
|
||||
title="Delete"
|
||||
target="_blank"
|
||||
href={makeRemovalRequest(
|
||||
categoryName,
|
||||
sectionName,
|
||||
serviceName,
|
||||
yamlContent,
|
||||
)}
|
||||
>
|
||||
<FontAwesome iconName="delete" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 3.5rem;
|
||||
top: 1rem;
|
||||
width: 2.8rem;
|
||||
gap: 1rem;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
transition: all 0.2s ease-in-out;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
width: 1rem;
|
||||
.actions {
|
||||
position: absolute;
|
||||
right: 3.5rem;
|
||||
top: 1rem;
|
||||
width: 2.8rem;
|
||||
gap: 1rem;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
color: var(--accent-3);
|
||||
opacity: 1;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
width: 1rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
color: var(--accent-3);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,110 +1,111 @@
|
|||
---
|
||||
|
||||
import type { DiscordInfo } from '@utils/fetch-discord-info';
|
||||
import { formatDate, timeAgo } from '@utils/dates-n-stuff';
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte"
|
||||
|
||||
|
||||
interface Props {
|
||||
discordData: DiscordInfo;
|
||||
};
|
||||
discordData: DiscordInfo;
|
||||
}
|
||||
|
||||
const { discordData } = Astro.props;
|
||||
|
||||
|
||||
---
|
||||
|
||||
<div class="discord-info-wrapper">
|
||||
<h3>Discord</h3>
|
||||
|
||||
<h3>Discord</h3>
|
||||
<ul class="list-table">
|
||||
<li>
|
||||
<span class="lbl">Server Name</span>
|
||||
<span class="val"
|
||||
><img src={discordData.icon} width="16" />{discordData.name}</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Member Count</span>
|
||||
<span class="val"
|
||||
>{discordData.memberCount} ({discordData.memberOnlineCount} online)</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Initial Channel</span>
|
||||
<span class="val">{discordData.channel}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Inviter</span>
|
||||
<span class="val">{discordData.inviter || 'Anon'}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Join Link</span>
|
||||
<span class="val"
|
||||
><a href={`https://discord.com/invite/${discordData.inviteCode}`}
|
||||
>discord.com/invite/{discordData.inviteCode}</a
|
||||
></span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul class="list-table">
|
||||
<li>
|
||||
<span class="lbl">Server Name</span>
|
||||
<span class="val"><img src={discordData.icon} width="16" />{discordData.name}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Member Count</span>
|
||||
<span class="val">{discordData.memberCount} ({discordData.memberOnlineCount} online)</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Initial Channel</span>
|
||||
<span class="val">{discordData.channel}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Inviter</span>
|
||||
<span class="val">{discordData.inviter || 'Anon'}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Join Link</span>
|
||||
<span class="val"><a href={`https://discord.com/invite/${discordData.inviteCode}`}>discord.com/invite/{discordData.inviteCode}</a></span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{ discordData.banner && (<img class="banner" width="300" src={discordData.banner} />)}
|
||||
|
||||
|
||||
{
|
||||
discordData.banner && (
|
||||
<img class="banner" width="300" src={discordData.banner} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.discord-info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 400px;
|
||||
}
|
||||
.discord-info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
&.list-table {
|
||||
font-size: 0.9rem;
|
||||
padding-left: 0;
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.1rem 0;
|
||||
.lbl {
|
||||
font-weight: 400;
|
||||
}
|
||||
.val {
|
||||
img {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #5f53f440;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: 80%;
|
||||
margin: 1rem auto 0 auto;
|
||||
display: flex;
|
||||
border-radius: var(--curve-md);
|
||||
}
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
&.list-table {
|
||||
font-size: 0.9rem;
|
||||
padding-left: 0;
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.1rem 0;
|
||||
.lbl {
|
||||
font-weight: 400;
|
||||
}
|
||||
.val {
|
||||
img {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #5f53f440;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
width: 80%;
|
||||
margin: 1rem auto 0 auto;
|
||||
display: flex;
|
||||
border-radius: var(--curve-md);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,39 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { slugify } from '@utils/fetch-data';
|
||||
|
||||
import { slugify } from "@utils/fetch-data";
|
||||
let linkId = '';
|
||||
let done = false;
|
||||
let error = false;
|
||||
|
||||
let linkId = '';
|
||||
let done = false;
|
||||
let error = false;
|
||||
|
||||
const save = async () => {
|
||||
const savedServices = JSON.parse(localStorage.getItem('savedServices') || '[]');
|
||||
const inventoryTitle = localStorage.getItem('userTitle') || 'Anon\'s Inventory';
|
||||
const uniqueId = Math.random().toString(36).substring(2);
|
||||
const saveKey = `${uniqueId}_${slugify(inventoryTitle)}`;
|
||||
const url = 'https://awesome-privacy-share-api.as93.net';
|
||||
const data = { key: saveKey, services: savedServices };
|
||||
fetch(url, {
|
||||
const save = async () => {
|
||||
const savedServices = JSON.parse(
|
||||
localStorage.getItem('savedServices') || '[]',
|
||||
);
|
||||
const inventoryTitle =
|
||||
localStorage.getItem('userTitle') || "Anon's Inventory";
|
||||
const uniqueId = Math.random().toString(36).substring(2);
|
||||
const saveKey = `${uniqueId}_${slugify(inventoryTitle)}`;
|
||||
const url = 'https://awesome-privacy-share-api.as93.net';
|
||||
const data = { key: saveKey, services: savedServices };
|
||||
fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
linkId = data.key;
|
||||
done = true;
|
||||
error = false;
|
||||
navigator.clipboard.writeText(`https://awesome-privacy.xyz/inventory/${linkId}`);
|
||||
})
|
||||
.catch(error => {
|
||||
error = true;
|
||||
console.error('Error:', error)
|
||||
});
|
||||
};
|
||||
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
linkId = data.key;
|
||||
done = true;
|
||||
error = false;
|
||||
navigator.clipboard.writeText(
|
||||
`https://awesome-privacy.xyz/inventory/${linkId}`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
error = true;
|
||||
console.error('Error:', error);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="share-container">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
|
||||
import type { IoSApiResponse } from '@utils/fetch-ios-info';
|
||||
import { formatDate, timeAgo } from '@utils/dates-n-stuff';
|
||||
import { formatDate } from '@utils/dates-n-stuff';
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte"
|
||||
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ const makeRatingPercentage = (rating: number) => (rating / 5) * 100;
|
|||
|
||||
const roundRatings = (rating: number) => Math.round(rating * 100) / 100;
|
||||
|
||||
const putCommaInNumber = (num: number | any) => {
|
||||
const putCommaInNumber = (num: number | string | undefined) => {
|
||||
if (!num) return 'Unknown';
|
||||
return typeof num === 'number' ? num.toLocaleString() : num;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
---
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte";
|
||||
import { error } from "@utils/logger";
|
||||
const { github } = Astro.props;
|
||||
|
||||
// const [user, repo] = github.split("/");
|
||||
interface GitHubRepoData {
|
||||
stargazers_count?: number;
|
||||
forks_count?: number;
|
||||
open_issues_count?: number;
|
||||
language?: string;
|
||||
license?: { spdx_id?: string; name?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given `user/repo` fetch repository stats from the GitHub API data
|
||||
* If API key is available through env var, use it to increase rate limit
|
||||
* Returns the response data and status code (200 == success)
|
||||
* @param repo
|
||||
* @param repo
|
||||
*/
|
||||
const fetchGitHubData = async (repo: string) => {
|
||||
const apiKey = import.meta.env.GITHUB_API_KEY;
|
||||
|
|
@ -17,13 +24,13 @@ const fetchGitHubData = async (repo: string) => {
|
|||
headers.append("Authorization", `token ${apiKey}`);
|
||||
}
|
||||
|
||||
let data = {};
|
||||
let statusCode = 0;
|
||||
let data: GitHubRepoData = {};
|
||||
let statusCode;
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/${repo}`, {
|
||||
headers: headers,
|
||||
}).catch((e) => {
|
||||
console.error(`Network error: ${e.message}`);
|
||||
error('GitHub API', `Network error for ${repo}: ${e.message}`);
|
||||
// Return a placeholder response to handle this gracefully
|
||||
return null;
|
||||
});
|
||||
|
|
@ -39,7 +46,7 @@ const fetchGitHubData = async (repo: string) => {
|
|||
if (response.ok) {
|
||||
data = await response.json();
|
||||
} else {
|
||||
console.error(`HTTP error: Received status code ${response.status}`);
|
||||
error('GitHub API', `HTTP ${response.status} for ${repo}`);
|
||||
}
|
||||
|
||||
return { data, statusCode };
|
||||
|
|
@ -50,7 +57,7 @@ const fetchGitHubData = async (repo: string) => {
|
|||
* Given a license object, return SPDX ID, or a formatted name
|
||||
* @param license
|
||||
*/
|
||||
const formatLicense = (license: { spdx_id?: string, name?: string }) => {
|
||||
const formatLicense = (license?: { spdx_id?: string, name?: string }) => {
|
||||
if (!license) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
|
@ -65,7 +72,8 @@ const formatLicense = (license: { spdx_id?: string, name?: string }) => {
|
|||
* E.g. If greater than thousand, then return in k format
|
||||
* @param num
|
||||
*/
|
||||
const formatBigNumber = (num: number) => {
|
||||
const formatBigNumber = (num: number | undefined) => {
|
||||
if (num == null) return 0;
|
||||
if (num > 1000) {
|
||||
return `${(num / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
|
@ -73,7 +81,7 @@ const formatBigNumber = (num: number) => {
|
|||
}
|
||||
|
||||
// Initiate GitHub fetch, and make available to the component
|
||||
const stats = (await fetchGitHubData(github)) as any;
|
||||
const stats = await fetchGitHubData(github);
|
||||
|
||||
const {
|
||||
stargazers_count, forks_count, open_issues_count, language, license,
|
||||
|
|
|
|||
|
|
@ -1,172 +1,191 @@
|
|||
---
|
||||
|
||||
import type { RedditData } from '@utils/fetch-reddit-info';
|
||||
import { timestampToDate, timeAgo } from '@utils/dates-n-stuff';
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte"
|
||||
|
||||
import { timestampToDate } from '@utils/dates-n-stuff';
|
||||
|
||||
interface Props {
|
||||
redditData: RedditData;
|
||||
};
|
||||
redditData: RedditData;
|
||||
}
|
||||
|
||||
const { redditData } = Astro.props;
|
||||
|
||||
---
|
||||
|
||||
<div class="reddit-info-wrapper">
|
||||
<div class="left">
|
||||
<h3>Reddit</h3>
|
||||
<p class="website-title">
|
||||
<img src={redditData.info.icon} width="16" />
|
||||
{redditData.info.title || redditData.info.name}
|
||||
</p>
|
||||
<p class="website-description">{redditData.info.description}</p>
|
||||
{redditData.info.banner && (<img class="banner" width="300" src={redditData.info.banner} alt="Banner" />)}
|
||||
<ul class="list-table">
|
||||
{redditData.info.dateCreated && (
|
||||
<li>
|
||||
<span class="lbl">Created at</span>
|
||||
<span class="val">{timestampToDate(redditData.info.dateCreated * 1000)}</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<span class="lbl">Members</span>
|
||||
<span class="val">{redditData.info.subscribers}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Join</span>
|
||||
<span class="val"><a href={`https://reddit.com/${redditData.info.name}`}>{redditData.info.name}</a></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h4>Posts</h4>
|
||||
<ul class="posts">
|
||||
{redditData.posts.map((post) => (
|
||||
<li title={post.body}>
|
||||
○ <a href={post.url} target="_blank">{post.title}</a>
|
||||
<span class="votes">(▲ {post.upVotes} ▼ {post.downVotes})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="left">
|
||||
<h3>Reddit</h3>
|
||||
<p class="website-title">
|
||||
<img src={redditData.info.icon} width="16" />
|
||||
{redditData.info.title || redditData.info.name}
|
||||
</p>
|
||||
<p class="website-description">{redditData.info.description}</p>
|
||||
{
|
||||
redditData.info.banner && (
|
||||
<img
|
||||
class="banner"
|
||||
width="300"
|
||||
src={redditData.info.banner}
|
||||
alt="Banner"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<ul class="list-table">
|
||||
{
|
||||
redditData.info.dateCreated && (
|
||||
<li>
|
||||
<span class="lbl">Created at</span>
|
||||
<span class="val">
|
||||
{timestampToDate(redditData.info.dateCreated * 1000)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
<li>
|
||||
<span class="lbl">Members</span>
|
||||
<span class="val">{redditData.info.subscribers}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="lbl">Join</span>
|
||||
<span class="val"
|
||||
><a href={`https://reddit.com/${redditData.info.name}`}
|
||||
>{redditData.info.name}</a
|
||||
></span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h4>Posts</h4>
|
||||
<ul class="posts">
|
||||
{
|
||||
redditData.posts.map((post) => (
|
||||
<li title={post.body}>
|
||||
○{' '}
|
||||
<a href={post.url} target="_blank">
|
||||
{post.title}
|
||||
</a>
|
||||
<span class="votes">
|
||||
(▲ {post.upVotes} ▼ {post.downVotes})
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.reddit-info-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
.left, .right {
|
||||
width: calc(50% - 1rem);
|
||||
@media screen and (max-width: 768px){
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.reddit-info-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
.left,
|
||||
.right {
|
||||
width: calc(50% - 1rem);
|
||||
@media screen and (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: 0.5rem auto;
|
||||
border-radius: var(--curve-md);
|
||||
width: 100%;
|
||||
}
|
||||
.banner {
|
||||
margin: 0.5rem auto;
|
||||
border-radius: var(--curve-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
:global(svg) {
|
||||
width: 1rem;
|
||||
}
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
}
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
:global(svg) {
|
||||
width: 1rem;
|
||||
}
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
&.list-table {
|
||||
font-size: 0.9rem;
|
||||
padding-left: 0;
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.1rem 0;
|
||||
.lbl {
|
||||
font-weight: 400;
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #5f53f440;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.posts {
|
||||
list-style: circle;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.9rem;
|
||||
li {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
a {
|
||||
max-width: 80%;
|
||||
}
|
||||
.votes {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.website-title, .website-description {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
border-left: 2px solid var(--accent-3);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.website-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.website-description {
|
||||
font-style: italic;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
.explainer {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
ul {
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
img {
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
&.list-table {
|
||||
font-size: 0.9rem;
|
||||
padding-left: 0;
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.1rem 0;
|
||||
.lbl {
|
||||
font-weight: 400;
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid #5f53f440;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.posts {
|
||||
list-style: circle;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.9rem;
|
||||
li {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
a {
|
||||
max-width: 80%;
|
||||
}
|
||||
.votes {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.website-title,
|
||||
.website-description {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
border-left: 2px solid var(--accent-3);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.website-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.website-description {
|
||||
font-style: italic;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
.explainer {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import FontAwesome from "@components/form/FontAwesome.svelte";
|
||||
import { slugify } from "@utils/fetch-data";
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
import { slugify } from '@utils/fetch-data';
|
||||
|
||||
export let categoryName: string;
|
||||
export let sectionName: string;
|
||||
|
|
@ -34,23 +34,24 @@
|
|||
</script>
|
||||
|
||||
<div class="wrapper-or-something">
|
||||
<button
|
||||
class={`save-container ${isSaved ? 'saved' : ''} ${showLabel ? 'label-button' : ''}`}
|
||||
title={`Save ${serviceName}`}
|
||||
on:click={toggleSave}>
|
||||
{#if showLabel }
|
||||
<span>
|
||||
{isSaved ? 'Saved' : 'Save'}
|
||||
</span>
|
||||
{/if}
|
||||
<FontAwesome iconName="saveListing"/>
|
||||
</button>
|
||||
<button
|
||||
class={`save-container ${isSaved ? 'saved' : ''} ${showLabel ? 'label-button' : ''}`}
|
||||
title={`Save ${serviceName}`}
|
||||
on:click={toggleSave}
|
||||
>
|
||||
{#if showLabel}
|
||||
<span>
|
||||
{isSaved ? 'Saved' : 'Save'}
|
||||
</span>
|
||||
{/if}
|
||||
<FontAwesome iconName="saveListing" />
|
||||
</button>
|
||||
|
||||
{#if showLabel && isSaved }
|
||||
<div class="done-msg">
|
||||
You can view all saved items in your <a href="/inventory">Inventory</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showLabel && isSaved}
|
||||
<div class="done-msg">
|
||||
You can view all saved items in your <a href="/inventory">Inventory</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -71,7 +72,7 @@
|
|||
font-size: 1.2rem;
|
||||
opacity: 0.8;
|
||||
color: var(--foreground);
|
||||
font-family: "Lekton";
|
||||
font-family: 'Lekton';
|
||||
}
|
||||
:global(svg) {
|
||||
color: var(--foreground);
|
||||
|
|
@ -98,9 +99,8 @@
|
|||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
border: 1px solid var(--box-outline);
|
||||
background: var(--background-form);
|
||||
|
||||
|
||||
&:hover {
|
||||
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
max-width: 165px;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
@media(max-width: 768px) {
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,54 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
import type { Category, Service } from '../../types/Service';
|
||||
import { slugify } from "@utils/fetch-data";
|
||||
import ServiceCard from './ServiceCard.svelte';
|
||||
import type { Category, Service } from '../../types/Service';
|
||||
import { slugify } from '@utils/fetch-data';
|
||||
import ServiceCard from './ServiceCard.svelte';
|
||||
|
||||
export let allData: Category[];
|
||||
export let serviceList: string[] | null = null;
|
||||
export let allData: Category[];
|
||||
export let serviceList: string[] | null = null;
|
||||
|
||||
interface SavedServices {
|
||||
category: string;
|
||||
section: string;
|
||||
service: Service;
|
||||
}
|
||||
interface SavedServices {
|
||||
category: string;
|
||||
section: string;
|
||||
service: Service;
|
||||
}
|
||||
|
||||
const savedServices = writable<SavedServices[]>([]);
|
||||
const savedServices = writable<SavedServices[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
const results: SavedServices[] = [];
|
||||
const saved = serviceList || JSON.parse(localStorage.getItem('savedServices') || '[]');
|
||||
saved.forEach((serviceId: string) => {
|
||||
const parts = serviceId.split('/');
|
||||
const categoryName = parts[0];
|
||||
const sectionName = parts[1];
|
||||
const serviceName = parts[2];
|
||||
onMount(async () => {
|
||||
const results: SavedServices[] = [];
|
||||
const saved =
|
||||
serviceList || JSON.parse(localStorage.getItem('savedServices') || '[]');
|
||||
saved.forEach((serviceId: string) => {
|
||||
const parts = serviceId.split('/');
|
||||
const categoryName = parts[0];
|
||||
const sectionName = parts[1];
|
||||
const serviceName = parts[2];
|
||||
|
||||
const category = allData.find((category) => slugify(category.name) === categoryName);
|
||||
if (!category) return;
|
||||
const section = category.sections.find((section) => slugify(section.name) === sectionName);
|
||||
if (!section) return;
|
||||
const service = section.services.find((service) => slugify(service.name) === serviceName);
|
||||
if (!service) return;
|
||||
results.push({ category: category.name, section: section.name, service});
|
||||
const category = allData.find(
|
||||
(category) => slugify(category.name) === categoryName,
|
||||
);
|
||||
if (!category) return;
|
||||
const section = category.sections.find(
|
||||
(section) => slugify(section.name) === sectionName,
|
||||
);
|
||||
if (!section) return;
|
||||
const service = section.services.find(
|
||||
(service) => slugify(service.name) === serviceName,
|
||||
);
|
||||
if (!service) return;
|
||||
results.push({ category: category.name, section: section.name, service });
|
||||
});
|
||||
savedServices.set(results || []);
|
||||
});
|
||||
savedServices.set(results || []);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if $savedServices.length > 0}
|
||||
<div class="saved-services">
|
||||
{#each $savedServices as thingy}
|
||||
{#each $savedServices as thingy (thingy.service.name + thingy.section)}
|
||||
<ServiceCard
|
||||
categoryName={thingy.category}
|
||||
sectionName={thingy.section}
|
||||
|
|
@ -52,10 +58,13 @@ onMount(async () => {
|
|||
</div>
|
||||
{:else if !serviceList}
|
||||
<div class="nothing-yet">
|
||||
<p>Here you'll find a list of all the software and services you've bookmarked.</p>
|
||||
<p>
|
||||
Here you'll find a list of all the software and services you've
|
||||
bookmarked.
|
||||
</p>
|
||||
<small>
|
||||
All data is stored on-device, in your browser's local storage,
|
||||
and not sent anywhere unless you choose to share it
|
||||
All data is stored on-device, in your browser's local storage, and not
|
||||
sent anywhere unless you choose to share it
|
||||
</small>
|
||||
<p class="nope">Nothing saved yet!</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@
|
|||
import { onMount } from 'svelte';
|
||||
import Fuse from 'fuse.js';
|
||||
import { slugify } from '@utils/fetch-data';
|
||||
import type { Category, Section, Service, ShortService } from '../../types/Service';
|
||||
import type { Category } from '../../types/Service';
|
||||
import { formatLink } from '@utils/parse-markdown';
|
||||
import { prepareSearchItems, searchOptions } from '@utils/do-searchy-searchy';
|
||||
import type { SearchItem } from '@utils/do-searchy-searchy';
|
||||
|
||||
export let data: Category[];
|
||||
export let previousSearch: string | undefined = undefined;
|
||||
|
||||
let fuse: Fuse<any>;
|
||||
let fuse: Fuse<SearchItem>;
|
||||
let searchQuery = '';
|
||||
let results: any[] = [];
|
||||
let results: SearchItem[];
|
||||
|
||||
// Initialize Fuse.js
|
||||
onMount(() => {
|
||||
|
|
@ -20,9 +21,9 @@
|
|||
});
|
||||
|
||||
const makeResultLink = (cat?: string, sec?: string, itm?: string) => {
|
||||
if (!cat) return '/'
|
||||
if (!sec) return `/${slugify(cat)}`
|
||||
if (!itm) return `/${slugify(cat)}/${slugify(sec)}`
|
||||
if (!cat) return '/';
|
||||
if (!sec) return `/${slugify(cat)}`;
|
||||
if (!itm) return `/${slugify(cat)}/${slugify(sec)}`;
|
||||
return `/${slugify(cat)}/${slugify(sec)}/${slugify(itm)}`;
|
||||
};
|
||||
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
|
||||
const makeTitle = (typ: string, desc: string) => {
|
||||
if (desc && typ === 'Service') {
|
||||
return `${desc.slice(0, 60)}...`
|
||||
return `${desc.slice(0, 60)}...`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
|
@ -59,7 +60,10 @@
|
|||
|
||||
// Watch for changes in the search query and update results
|
||||
$: if (searchQuery) {
|
||||
results = fuse.search(searchQuery).map(result => result.item).splice(0, 25);
|
||||
results = fuse
|
||||
.search(searchQuery)
|
||||
.map((result) => result.item)
|
||||
.splice(0, 25);
|
||||
} else {
|
||||
results = [];
|
||||
}
|
||||
|
|
@ -79,35 +83,48 @@
|
|||
bind:value={searchQuery}
|
||||
on:keydown={handleKeyDown}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{#if searchQuery.length > 0}
|
||||
<div class="suggestions">
|
||||
<ul>
|
||||
{#each results as result}
|
||||
<li class="result-row">
|
||||
<a
|
||||
href={makeResultLink(result.category, result.sectionName, result.name)}
|
||||
title={makeTitle(result.type, result.description)}
|
||||
{#each results as result (result.name + result.category + result.sectionName)}
|
||||
<li class="result-row">
|
||||
<a
|
||||
href={makeResultLink(
|
||||
result.category,
|
||||
result.sectionName,
|
||||
result.name,
|
||||
)}
|
||||
title={makeTitle(result.type, result.description)}
|
||||
>
|
||||
<span class="name">
|
||||
{#if result.type === 'Service'}
|
||||
<img src={makeLogoSrc(result.logo, result.url)} alt={result.name} width="20" height="20" loading="lazy" />
|
||||
{/if}
|
||||
|
||||
{makeResultText(result.category, result.sectionName, result.name)}
|
||||
|
||||
{#if result.itemCount}
|
||||
<i>({result.itemCount})</i>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="path">
|
||||
{result.category ? `${result.category}` : ''}
|
||||
{result.sectionName ? `➔ ${result.sectionName}` : ''}
|
||||
{result.name ? `➔ ${result.name}` : ''}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<span class="name">
|
||||
{#if result.type === 'Service'}
|
||||
<img
|
||||
src={makeLogoSrc(result.logo, result.url)}
|
||||
alt={result.name}
|
||||
width="20"
|
||||
height="20"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{makeResultText(
|
||||
result.category,
|
||||
result.sectionName,
|
||||
result.name,
|
||||
)}
|
||||
|
||||
{#if result.itemCount}
|
||||
<i>({result.itemCount})</i>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="path">
|
||||
{result.category ? `${result.category}` : ''}
|
||||
{result.sectionName ? `➔ ${result.sectionName}` : ''}
|
||||
{result.name ? `➔ ${result.name}` : ''}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -115,103 +132,101 @@
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.search-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
margin: 1rem auto;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 80vw;
|
||||
label {
|
||||
margin: 0.5rem 0;
|
||||
.search-wrap {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
.enter-hint {
|
||||
font-size:0.8rem;
|
||||
opacity: 0.7;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
margin: 1rem auto;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 80vw;
|
||||
label {
|
||||
margin: 0.5rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
.enter-hint {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.8rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-lg);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
z-index: 4;
|
||||
background: var(--accent-fg);
|
||||
color: var(--foreground);
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 3px 3px 0 var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
ul {
|
||||
position: absolute;
|
||||
background: var(--background-form);
|
||||
z-index: 3;
|
||||
width: 100%;
|
||||
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
input {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.8rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: 0 0 var(--curve-lg) var(--curve-lg);
|
||||
border-radius: var(--curve-lg);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
transform: translateY(-0.5rem);
|
||||
max-height: 500px;
|
||||
overflow-y: scroll;
|
||||
background: var(--background-form);
|
||||
li.result-row {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
.name {
|
||||
z-index: 4;
|
||||
background: var(--accent-fg);
|
||||
color: var(--foreground);
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 3px 3px 0 var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
ul {
|
||||
position: absolute;
|
||||
background: var(--background-form);
|
||||
z-index: 3;
|
||||
width: 100%;
|
||||
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: 0 0 var(--curve-lg) var(--curve-lg);
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
transform: translateY(-0.5rem);
|
||||
max-height: 500px;
|
||||
overflow-y: scroll;
|
||||
background: var(--background-form);
|
||||
li.result-row {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
i {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
i {
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
img {
|
||||
border-radius: var(--curve-md);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
background: #f453974d;
|
||||
padding: 1px;
|
||||
}
|
||||
}
|
||||
img {
|
||||
border-radius: var(--curve-md);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
background: #f453974d;
|
||||
padding: 1px;
|
||||
.path {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.path {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--accent);
|
||||
.name i {
|
||||
color: var(--accent-fg);
|
||||
&:hover {
|
||||
background: var(--accent);
|
||||
.name i {
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
---
|
||||
|
||||
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
import { slugify } from '../../utils/fetch-data';
|
||||
|
||||
|
|
@ -9,144 +7,161 @@ import type { Section } from '../../types/Service';
|
|||
interface Props {
|
||||
title: string;
|
||||
sections: Section[];
|
||||
bigTitle?: boolean;
|
||||
bigTitle?: boolean;
|
||||
}
|
||||
|
||||
const { title, sections, bigTitle } = Astro.props;
|
||||
|
||||
---
|
||||
|
||||
<div class="wrap">
|
||||
{
|
||||
bigTitle ? (
|
||||
<h2>
|
||||
<FontAwesome iconName={slugify(title)} />
|
||||
{title}{' '}
|
||||
</h2>
|
||||
) : (
|
||||
<a class="category-title" href={`/${slugify(title)}`}>
|
||||
<h3>{title}</h3>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{ bigTitle ?
|
||||
<h2><FontAwesome iconName={slugify(title)} />{title} </h2> :
|
||||
<a class="category-title" href={`/${slugify(title)}`}><h3>{title}</h3></a>
|
||||
}
|
||||
{
|
||||
!bigTitle && (
|
||||
<span class="section-icon">
|
||||
<FontAwesome iconName={slugify(title)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
{ !bigTitle && <span class="section-icon"><FontAwesome iconName={slugify(title)} /></span> }
|
||||
|
||||
<ul>
|
||||
{sections.map((section) => (
|
||||
<li class="section">
|
||||
<a href={`/${slugify(title)}/${slugify(section.name)}`}>
|
||||
<span>{section.name}</span>
|
||||
<span class="service-count">({section.services ? section.services.length : 0})</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul>
|
||||
{
|
||||
sections.map((section) => (
|
||||
<li class="section">
|
||||
<a href={`/${slugify(title)}/${slugify(section.name)}`}>
|
||||
<span>{section.name}</span>
|
||||
<span class="service-count">
|
||||
({section.services ? section.services.length : 0})
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.wrap {
|
||||
position: relative;
|
||||
&:hover {
|
||||
.section-icon :global(svg) {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
&:hover {
|
||||
.section-icon :global(svg){
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-size: 2rem;
|
||||
margin: -2rem 0 2rem -2rem;
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
border: 2px solid var(--box-outline);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
width: fit-content;
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-size: 2rem;
|
||||
margin: -2rem 0 2rem -2rem;
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
border: 2px solid var(--box-outline);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
width: fit-content;
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
}
|
||||
.category-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
h3 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
position: relative;
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 3px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent);
|
||||
transition:
|
||||
width 0.3s ease 0s,
|
||||
left 0.3s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
width: 80%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
h3 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
position: relative;
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease 0s, left 0.3s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
width: 80%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
ul {
|
||||
list-style: circle;
|
||||
padding-left: 1rem;
|
||||
li {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
position: relative;
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 2px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent-3);
|
||||
transition:
|
||||
width 0.15s ease 0s,
|
||||
left 0.15s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
text-decoration: underline;
|
||||
width: 80%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
.service-count {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: circle;
|
||||
padding-left: 1rem;
|
||||
li {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
position: relative;
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 2px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent-3);
|
||||
transition: width 0.15s ease 0s, left 0.15s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
text-decoration: underline;
|
||||
width: 80%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
.service-count {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: fit-content;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.5;
|
||||
text-shadow: 3px 3px 0 black;
|
||||
color: var(--accent-3);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
.section-icon {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: fit-content;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.5;
|
||||
text-shadow: 3px 3px 0 black;
|
||||
color: var(--accent-3);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
|
||||
import { formatLink } from '@utils/parse-markdown';
|
||||
import type { Service } from 'src/types/Service';
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
|
|
@ -7,86 +6,91 @@ import SaveListing from '@components/things/SaveListing.svelte';
|
|||
import { slugify } from '@utils/fetch-data';
|
||||
|
||||
interface Props {
|
||||
service: Service;
|
||||
categoryName: string;
|
||||
sectionName: string;
|
||||
service: Service;
|
||||
categoryName: string;
|
||||
sectionName: string;
|
||||
}
|
||||
|
||||
const {
|
||||
service,
|
||||
sectionName,
|
||||
categoryName,
|
||||
} = Astro.props;
|
||||
|
||||
const { service, sectionName, categoryName } = Astro.props;
|
||||
---
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const serviceIcons = document.querySelectorAll<HTMLImageElement>('.service-icon');
|
||||
const broke = '/broken-image.png';
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const serviceIcons =
|
||||
document.querySelectorAll<HTMLImageElement>('.service-icon');
|
||||
const broke = '/broken-image.png';
|
||||
|
||||
serviceIcons.forEach(function(icon) {
|
||||
icon.onerror = function() {
|
||||
const imgElement = this as HTMLImageElement;
|
||||
const serviceUrl = imgElement.getAttribute('data-service-url');
|
||||
const newSrcAttribute = (imgElement.src.includes('on.ho') ? broke : `https://icon.horse/icon/${serviceUrl}`);
|
||||
imgElement.src = imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
|
||||
imgElement.onerror = null;
|
||||
};
|
||||
});
|
||||
});
|
||||
serviceIcons.forEach(function (icon) {
|
||||
icon.onerror = function () {
|
||||
const imgElement = this as HTMLImageElement;
|
||||
const serviceUrl = imgElement.getAttribute('data-service-url');
|
||||
const newSrcAttribute = imgElement.src.includes('on.ho')
|
||||
? broke
|
||||
: `https://icon.horse/icon/${serviceUrl}`;
|
||||
imgElement.src =
|
||||
imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
|
||||
imgElement.onerror = null;
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<div class="service" id={slugify(service.name)}>
|
||||
<div class="service-head">
|
||||
<a class="service-title" href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>
|
||||
<h4>{service.name}</h4>
|
||||
</a>
|
||||
{service.followWith && <p class="follow-with">({service.followWith})</p> }
|
||||
</div>
|
||||
<div class="service-head">
|
||||
<a
|
||||
class="service-title"
|
||||
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
|
||||
>
|
||||
<h4>{service.name}</h4>
|
||||
</a>
|
||||
{service.followWith && <p class="follow-with">({service.followWith})</p>}
|
||||
</div>
|
||||
|
||||
<div class="save-listing">
|
||||
<SaveListing client:visible
|
||||
categoryName={categoryName}
|
||||
sectionName={sectionName}
|
||||
serviceName={service.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="save-listing">
|
||||
<SaveListing
|
||||
client:visible
|
||||
categoryName={categoryName}
|
||||
sectionName={sectionName}
|
||||
serviceName={service.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="service-body">
|
||||
<img
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="service-icon"
|
||||
alt={`${service.name} Icon`}
|
||||
data-service-url={formatLink(service.url)}
|
||||
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
|
||||
/>
|
||||
<div class="service-body">
|
||||
<p set:html={service.description}></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-body">
|
||||
<img
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="service-icon"
|
||||
alt={`${service.name} Icon`}
|
||||
data-service-url={formatLink(service.url)}
|
||||
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
|
||||
/>
|
||||
<div class="service-body">
|
||||
<p set:html={service.description} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-links">
|
||||
<a class="link" href={service.url}>
|
||||
<FontAwesome iconName="website"/> <span>{formatLink(service.url)}</span>
|
||||
</a>
|
||||
{service.github &&
|
||||
<a class="link" href={`https://github.com/${service.github}`}>
|
||||
<FontAwesome iconName="sourceCode"/> GitHub
|
||||
</a>
|
||||
}
|
||||
<a href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>
|
||||
<FontAwesome iconName="viewReport" /> View Report ➔
|
||||
</a>
|
||||
<div class="service-links">
|
||||
<a class="link" href={service.url}>
|
||||
<FontAwesome iconName="website" />
|
||||
<span>{formatLink(service.url)}</span>
|
||||
</a>
|
||||
{
|
||||
service.github && (
|
||||
<a class="link" href={`https://github.com/${service.github}`}>
|
||||
<FontAwesome iconName="sourceCode" /> GitHub
|
||||
</a>
|
||||
)
|
||||
}
|
||||
<a
|
||||
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
|
||||
>
|
||||
<FontAwesome iconName="viewReport" /> View Report ➔
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import './service-card.scss';
|
||||
</style>
|
||||
<style lang="scss">
|
||||
@use './service-card.scss';
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@
|
|||
|
||||
<div class="service" id={serviceRef}>
|
||||
<div class="service-head">
|
||||
<a class="service-title" href={`/${categorySlug}/${sectionSlug}/${serviceRef}`}>
|
||||
<a
|
||||
class="service-title"
|
||||
href={`/${categorySlug}/${sectionSlug}/${serviceRef}`}
|
||||
>
|
||||
<h4>{service.name}</h4>
|
||||
</a>
|
||||
{#if service.followWith}
|
||||
|
|
@ -26,11 +29,7 @@
|
|||
</div>
|
||||
|
||||
<div class="save-listing">
|
||||
<SaveListing
|
||||
categoryName={categoryName}
|
||||
sectionName={sectionName}
|
||||
serviceName={service.name}
|
||||
/>
|
||||
<SaveListing {categoryName} {sectionName} serviceName={service.name} />
|
||||
</div>
|
||||
|
||||
<div class="service-body">
|
||||
|
|
@ -45,6 +44,7 @@
|
|||
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
|
||||
/>
|
||||
<div class="service-body">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- description is from curated YAML data, not user input -->
|
||||
<p>{@html service.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -65,5 +65,5 @@
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import './service-card.scss';
|
||||
@use './service-card.scss';
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
|
||||
import Button from '@components/form/Button.astro';
|
||||
import { parseMarkdown, formatLink } from '@utils/parse-markdown';
|
||||
import type { Service } from 'src/types/Service';
|
||||
|
|
@ -11,326 +10,387 @@ import GitHubMetrics from '@components/things/ItemGitHubMetrics.astro';
|
|||
import SaveListing from '@components/things/SaveListing.svelte';
|
||||
|
||||
interface Props {
|
||||
services: Service[];
|
||||
subHeading?: boolean;
|
||||
buttonLink?: string;
|
||||
noGitHubMetrics?: boolean;
|
||||
sectionName: string;
|
||||
categoryName: string;
|
||||
services: Service[];
|
||||
subHeading?: boolean;
|
||||
buttonLink?: string;
|
||||
noGitHubMetrics?: boolean;
|
||||
sectionName: string;
|
||||
categoryName: string;
|
||||
}
|
||||
|
||||
const {
|
||||
services,
|
||||
subHeading,
|
||||
buttonLink,
|
||||
noGitHubMetrics,
|
||||
sectionName,
|
||||
categoryName,
|
||||
services,
|
||||
subHeading,
|
||||
buttonLink,
|
||||
noGitHubMetrics,
|
||||
sectionName,
|
||||
categoryName,
|
||||
} = Astro.props;
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const serviceIcons = document.querySelectorAll<HTMLImageElement>('.service-icon');
|
||||
const broke = '/broken-image.png';
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const serviceIcons =
|
||||
document.querySelectorAll<HTMLImageElement>('.service-icon');
|
||||
const broke = '/broken-image.png';
|
||||
|
||||
serviceIcons.forEach(function(icon) {
|
||||
icon.onerror = function() {
|
||||
const imgElement = this as HTMLImageElement;
|
||||
const serviceUrl = imgElement.getAttribute('data-service-url');
|
||||
const newSrcAttribute = (imgElement.src.includes('on.ho') ? broke : `https://icon.horse/icon/${serviceUrl}`);
|
||||
imgElement.src = imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
|
||||
imgElement.onerror = null;
|
||||
};
|
||||
});
|
||||
});
|
||||
serviceIcons.forEach(function (icon) {
|
||||
icon.onerror = function () {
|
||||
const imgElement = this as HTMLImageElement;
|
||||
const serviceUrl = imgElement.getAttribute('data-service-url');
|
||||
const newSrcAttribute = imgElement.src.includes('on.ho')
|
||||
? broke
|
||||
: `https://icon.horse/icon/${serviceUrl}`;
|
||||
imgElement.src =
|
||||
imgElement.src !== newSrcAttribute ? newSrcAttribute : broke;
|
||||
imgElement.onerror = null;
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<section>
|
||||
{services && services.length > 0 ? (
|
||||
<ul>
|
||||
{services.map((service: Service) => (
|
||||
<li id={slugify(service.name)}>
|
||||
<DeleteListing client:load categoryName={categoryName} sectionName={sectionName} serviceName={service.name} />
|
||||
<div class="save-listing">
|
||||
<SaveListing client:visible
|
||||
categoryName={categoryName}
|
||||
sectionName={sectionName}
|
||||
serviceName={service.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="service-head">
|
||||
<img
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="service-icon"
|
||||
alt={`${service.name} Icon`}
|
||||
data-service-url={formatLink(service.url)}
|
||||
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
|
||||
/>
|
||||
{
|
||||
services && services.length > 0 ? (
|
||||
<ul>
|
||||
{services.map((service: Service) => (
|
||||
<li id={slugify(service.name)}>
|
||||
<DeleteListing
|
||||
client:load
|
||||
categoryName={categoryName}
|
||||
sectionName={sectionName}
|
||||
serviceName={service.name}
|
||||
/>
|
||||
<div class="save-listing">
|
||||
<SaveListing
|
||||
client:visible
|
||||
categoryName={categoryName}
|
||||
sectionName={sectionName}
|
||||
serviceName={service.name}
|
||||
/>
|
||||
</div>
|
||||
<div class="service-head">
|
||||
<img
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="service-icon"
|
||||
alt={`${service.name} Icon`}
|
||||
data-service-url={formatLink(service.url)}
|
||||
src={
|
||||
service.icon ||
|
||||
`https://icon.horse/icon/${formatLink(service.url)}`
|
||||
}
|
||||
/>
|
||||
|
||||
<a class="service-title" href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>
|
||||
{subHeading ? <h4>{service.name}</h4> : <h3>{service.name}</h3>}
|
||||
</a>
|
||||
{service.followWith && <p class="follow-with">({service.followWith})</p> }
|
||||
<a class="service-link" href={service.url}>{formatLink(service.url)}</a>
|
||||
</div>
|
||||
<div class="service-body">
|
||||
<p set:html={parseMarkdown(service.description)}></p>
|
||||
<div class="service-stats">
|
||||
<div class="left">
|
||||
{ service.securityAudited && (
|
||||
<span class="meta-item great" title={`${service.name} has been security audited by an accredited auditor, with results published publicly`}>
|
||||
<FontAwesome iconName="securityAudited" /> Security Audited
|
||||
</span>
|
||||
)}
|
||||
{ service.acceptsCrypto && (
|
||||
<span class="meta-item great" title={`${service.name} accepts anonymous payment methods`}>
|
||||
<FontAwesome iconName="cryptoAccepted" /> Crypto Payments Accepted
|
||||
</span>
|
||||
)}
|
||||
{ service.securityAudited === false && (
|
||||
<span class="meta-item warning" title={`${service.name} has not been audited`}>
|
||||
<FontAwesome iconName="notSecurityAudited" /> No Security Audit
|
||||
</span>
|
||||
)}
|
||||
{ (service.openSource === false) && (
|
||||
<span class="warning">
|
||||
<FontAwesome iconName="closedSource" />
|
||||
Not Open Source
|
||||
</span>
|
||||
)}
|
||||
{ service.openSource || (service.github && service.openSource !== false) ? (
|
||||
<span class="meta-item great" title={`${service.name} is open source`}>
|
||||
<FontAwesome iconName="openSource" /> Open Source
|
||||
</span>
|
||||
) : null }
|
||||
{ service.github && !noGitHubMetrics && <GitHubMetrics github={service.github} /> }
|
||||
{ service.github && noGitHubMetrics && (
|
||||
<span class="meta-item" title={`View ${service.name} on GitHub`}>
|
||||
<a href={`https://github.com/${service.github}`} target="_blank">
|
||||
<FontAwesome iconName="github" /> {service.github}
|
||||
</a>
|
||||
</span>
|
||||
) }
|
||||
</div>
|
||||
<div class="view-service">
|
||||
<a href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}>View {service.name} Report</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="nothing-yet">
|
||||
<strong>⚠️ This section is still a work in progress ⚠️</strong><br />
|
||||
Check back soon, or help us complete it by submiting a pull request on GitHub.
|
||||
<br />
|
||||
<span class="quick-submit">Or submit an entry <a href="/submit">here</a></span>
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
class="service-title"
|
||||
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
|
||||
>
|
||||
{subHeading ? <h4>{service.name}</h4> : <h3>{service.name}</h3>}
|
||||
</a>
|
||||
{service.followWith && (
|
||||
<p class="follow-with">({service.followWith})</p>
|
||||
)}
|
||||
<a class="service-link" href={service.url}>
|
||||
{formatLink(service.url)}
|
||||
</a>
|
||||
</div>
|
||||
<div class="service-body">
|
||||
<p set:html={parseMarkdown(service.description)} />
|
||||
<div class="service-stats">
|
||||
<div class="left">
|
||||
{service.securityAudited && (
|
||||
<span
|
||||
class="meta-item great"
|
||||
title={`${service.name} has been security audited by an accredited auditor, with results published publicly`}
|
||||
>
|
||||
<FontAwesome iconName="securityAudited" /> Security
|
||||
Audited
|
||||
</span>
|
||||
)}
|
||||
{service.acceptsCrypto && (
|
||||
<span
|
||||
class="meta-item great"
|
||||
title={`${service.name} accepts anonymous payment methods`}
|
||||
>
|
||||
<FontAwesome iconName="cryptoAccepted" /> Crypto Payments
|
||||
Accepted
|
||||
</span>
|
||||
)}
|
||||
{service.securityAudited === false && (
|
||||
<span
|
||||
class="meta-item warning"
|
||||
title={`${service.name} has not been audited`}
|
||||
>
|
||||
<FontAwesome iconName="notSecurityAudited" /> No Security
|
||||
Audit
|
||||
</span>
|
||||
)}
|
||||
{service.openSource === false && (
|
||||
<span class="warning">
|
||||
<FontAwesome iconName="closedSource" />
|
||||
Not Open Source
|
||||
</span>
|
||||
)}
|
||||
{service.openSource ||
|
||||
(service.github && service.openSource !== false) ? (
|
||||
<span
|
||||
class="meta-item great"
|
||||
title={`${service.name} is open source`}
|
||||
>
|
||||
<FontAwesome iconName="openSource" /> Open Source
|
||||
</span>
|
||||
) : null}
|
||||
{service.github && !noGitHubMetrics && (
|
||||
<GitHubMetrics github={service.github} />
|
||||
)}
|
||||
{service.github && noGitHubMetrics && (
|
||||
<span
|
||||
class="meta-item"
|
||||
title={`View ${service.name} on GitHub`}
|
||||
>
|
||||
<a
|
||||
href={`https://github.com/${service.github}`}
|
||||
target="_blank"
|
||||
>
|
||||
<FontAwesome iconName="github" /> {service.github}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="view-service">
|
||||
<a
|
||||
href={`/${slugify(categoryName)}/${slugify(sectionName)}/${slugify(service.name)}`}
|
||||
>
|
||||
View {service.name} Report
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p class="nothing-yet">
|
||||
<>
|
||||
<strong>⚠️ This section is still a work in progress ⚠️</strong>
|
||||
<br />
|
||||
</>
|
||||
Check back soon, or help us complete it by submiting a pull request on
|
||||
GitHub.
|
||||
<br />
|
||||
<span class="quick-submit">
|
||||
Or submit an entry <a href="/submit">here</a>
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
{buttonLink && (
|
||||
<Button title={`View all ${categoryName}`} className="view-all" text="View More..." url={buttonLink} />
|
||||
)}
|
||||
{
|
||||
buttonLink && (
|
||||
<Button
|
||||
title={`View all ${categoryName}`}
|
||||
className="view-all"
|
||||
text="View More..."
|
||||
url={buttonLink}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
padding: 1rem 0;
|
||||
position: relative;
|
||||
&:not(:last-child) {
|
||||
border-bottom: 2px solid var(--accent-3);
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 1rem 0;
|
||||
position: relative;
|
||||
&:not(:last-child) {
|
||||
border-bottom: 2px solid var(--accent-3);
|
||||
}
|
||||
}
|
||||
|
||||
.nothing-yet {
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
.quick-submit {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 3rem 0;
|
||||
li {
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
.save-listing {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
}
|
||||
.service-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
.service-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
h3, h4 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.nothing-yet {
|
||||
font-size: 1.4rem;
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
.quick-submit {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
position: relative;
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 3px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent-3);
|
||||
transition: width 0.3s ease 0s, left 0.3s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--curve-sm);
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 3rem 0;
|
||||
li {
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
.save-listing {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
}
|
||||
.service-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
.service-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
h3,
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
color: var(--accent);
|
||||
}
|
||||
.follow-with {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
.service-link {
|
||||
max-width: 300px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.service-body {
|
||||
margin: 0.5rem 0 2rem;
|
||||
opacity: 0.8;
|
||||
:global(p) {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
:global(a) {
|
||||
color: var(--foregorund);
|
||||
}
|
||||
}
|
||||
.service-stats {
|
||||
.left {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.view-service {
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0.95;
|
||||
a {
|
||||
padding: 0.25rem 0.6rem;
|
||||
width: fit-content;
|
||||
right: 1rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--accent-3);
|
||||
color: var(--accent-fg);
|
||||
text-decoration: none;
|
||||
border-radius: var(--curve-md);
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
position: relative;
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 3px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent-3);
|
||||
transition:
|
||||
width 0.3s ease 0s,
|
||||
left 0.3s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-item, .warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// justify-content: center;
|
||||
gap: 0.25rem;
|
||||
// opacity: 0.6;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
:global(svg) {
|
||||
color: var(--foreground);
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
&:hover {
|
||||
color: var(--accent-3);
|
||||
:global(svg) {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.warning {
|
||||
color: var(--danger);
|
||||
:global(svg) {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
.great {
|
||||
color: #0fb953; // var(--success);
|
||||
:global(svg) {
|
||||
color: #0fb953; // var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.service-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--curve-sm);
|
||||
|
||||
section :global(.view-all) {
|
||||
width: fit-content;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
margin-top: -2.5rem;
|
||||
background: var(--accent-3);
|
||||
}
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
color: var(--accent);
|
||||
}
|
||||
.follow-with {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
.service-link {
|
||||
max-width: 300px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
li:hover :global(.actions) {
|
||||
opacity: 0.6;
|
||||
:global(a):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
:global(.actions a):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.service-body {
|
||||
margin: 0.5rem 0 2rem;
|
||||
opacity: 0.8;
|
||||
:global(p) {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
:global(a) {
|
||||
color: var(--foregorund);
|
||||
}
|
||||
}
|
||||
.service-stats {
|
||||
.left {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.view-service {
|
||||
transition: all 0.2s ease-in-out;
|
||||
opacity: 0.95;
|
||||
a {
|
||||
padding: 0.25rem 0.6rem;
|
||||
width: fit-content;
|
||||
right: 1rem;
|
||||
font-size: 0.9rem;
|
||||
background: var(--accent-3);
|
||||
color: var(--accent-fg);
|
||||
text-decoration: none;
|
||||
border-radius: var(--curve-md);
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.meta-item,
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// justify-content: center;
|
||||
gap: 0.25rem;
|
||||
// opacity: 0.6;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0;
|
||||
:global(svg) {
|
||||
color: var(--foreground);
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
&:hover {
|
||||
color: var(--accent-3);
|
||||
:global(svg) {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.warning {
|
||||
color: var(--danger);
|
||||
:global(svg) {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
.great {
|
||||
color: #0fb953; // var(--success);
|
||||
:global(svg) {
|
||||
color: #0fb953; // var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section :global(.view-all) {
|
||||
width: fit-content;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
margin-top: -2.5rem;
|
||||
background: var(--accent-3);
|
||||
}
|
||||
|
||||
li:hover :global(.actions) {
|
||||
opacity: 0.6;
|
||||
:global(a):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
:global(.actions a):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
import type { Category, Service } from "../../types/Service";
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Category, Service } from '../../types/Service';
|
||||
import { formatLink } from '@utils/parse-markdown';
|
||||
import { slugify } from '@utils/fetch-data';
|
||||
|
||||
|
|
@ -14,21 +14,23 @@
|
|||
|
||||
let results = writable<ServiceResult[]>([]);
|
||||
|
||||
const normalize = (str: string) => str.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const normalize = (str: string) =>
|
||||
str.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
const apiEndpoint = `https://awesome-privacy.as93.workers.dev/${searchTerm}`;
|
||||
const fetchedServices = await fetch(apiEndpoint)
|
||||
.then((response) => response.json())
|
||||
.then((data) => (JSON.parse(data) || []).map((servName: string) => normalize(servName)));
|
||||
.then((data) =>
|
||||
(JSON.parse(data) || []).map((servName: string) => normalize(servName)),
|
||||
);
|
||||
|
||||
const tmpResults: ServiceResult[] = [];
|
||||
categories.forEach((category) => {
|
||||
(category.sections || []).forEach((section) => {
|
||||
(section.services || []).forEach((service) => {
|
||||
if (fetchedServices.includes(normalize(service.name))) {
|
||||
const path = `/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`
|
||||
const path = `/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`;
|
||||
tmpResults.push({ ...service, path });
|
||||
return;
|
||||
}
|
||||
|
|
@ -45,35 +47,36 @@
|
|||
{#if $results.length > 1}
|
||||
<h3>Top Results</h3>
|
||||
{/if}
|
||||
<section>
|
||||
{#each $results as service (service)}
|
||||
<a class="service-result" href={service.path}>
|
||||
<div class="service-head">
|
||||
<img
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="service-icon"
|
||||
alt={`${service.name} Icon`}
|
||||
data-service-url={formatLink(service.url)}
|
||||
src={service.icon || `https://icon.horse/icon/${formatLink(service.url)}`}
|
||||
/>
|
||||
<div>
|
||||
<h4>
|
||||
{service.name}
|
||||
{#if service.followWith}
|
||||
<section>
|
||||
{#each $results as service (service)}
|
||||
<a class="service-result" href={service.path}>
|
||||
<div class="service-head">
|
||||
<img
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="service-icon"
|
||||
alt={`${service.name} Icon`}
|
||||
data-service-url={formatLink(service.url)}
|
||||
src={service.icon ||
|
||||
`https://icon.horse/icon/${formatLink(service.url)}`}
|
||||
/>
|
||||
<div>
|
||||
<h4>
|
||||
{service.name}
|
||||
{#if service.followWith}
|
||||
<p class="follow-with">({service.followWith})</p>
|
||||
{/if}
|
||||
</h4>
|
||||
<a class="service-link" href={service.url}>{formatLink(service.url)}</a>
|
||||
</div>
|
||||
{/if}
|
||||
</h4>
|
||||
<a class="service-link" href={service.url}
|
||||
>{formatLink(service.url)}</a
|
||||
>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
h3 {
|
||||
|
|
|
|||
|
|
@ -29,21 +29,23 @@
|
|||
h4 {
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
&:after {
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: "";
|
||||
content: '';
|
||||
display: block;
|
||||
height: 3px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent-3);
|
||||
transition: width 0.2s ease 0s, left 0.2s ease 0s;
|
||||
transition:
|
||||
width 0.2s ease 0s,
|
||||
left 0.2s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
&:hover:after {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,7 +68,7 @@
|
|||
:global(p) {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
width: calc(100% - 2rem);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { ViewTransitions } from 'astro:transitions'
|
||||
import { ViewTransitions } from 'astro:transitions';
|
||||
import NavBar from '@components/scafold/NavBar.astro';
|
||||
import Footer from '@components/scafold/Footer.astro';
|
||||
import config from '../site-config';
|
||||
|
|
@ -7,14 +7,14 @@ import config from '../site-config';
|
|||
interface Props {
|
||||
title?: string; // Page title
|
||||
description?: string; // Overide description tag
|
||||
keywords?: string; // Overide keywords tag
|
||||
keywords?: string; // Overide keywords tag
|
||||
hideNav?: boolean; // Don't show the navbar (just homepage)
|
||||
author?: string; // Author of the content
|
||||
customSchemaJson?: any; // Custom schema item
|
||||
customSchemaJson?: Record<string, unknown>; // Custom schema item
|
||||
breadcrumbs?: Array<{
|
||||
name: string;
|
||||
item: string;
|
||||
}>
|
||||
}>;
|
||||
}
|
||||
|
||||
const {
|
||||
|
|
@ -30,92 +30,120 @@ const {
|
|||
const makeBreadcrumbs = () => {
|
||||
if (!breadcrumbs) return null;
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": breadcrumbs.map((breadcrumb, index) => ({
|
||||
"@type": "ListItem",
|
||||
"position": index + 1,
|
||||
"name": breadcrumb.name,
|
||||
"item": `https://awesome-privacy.xyz/${breadcrumb.item}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: breadcrumbs.map((breadcrumb, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: breadcrumb.name,
|
||||
item: `https://awesome-privacy.xyz/${breadcrumb.item}`,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const makeSearchLd = () => {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"url": "https://awesome-privacy.xyz/",
|
||||
"potentialAction": [{
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://awesome-privacy.xyz/search?q={search_term_string}"
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
url: 'https://awesome-privacy.xyz/',
|
||||
potentialAction: [
|
||||
{
|
||||
'@type': 'SearchAction',
|
||||
target: {
|
||||
'@type': 'EntryPoint',
|
||||
urlTemplate:
|
||||
'https://awesome-privacy.xyz/search?q={search_term_string}',
|
||||
},
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}]
|
||||
}
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
|
||||
<ViewTransitions />
|
||||
|
||||
|
||||
<!-- Core info -->
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description}>
|
||||
<meta name="keywords" content={keywords}>
|
||||
<meta name="author" content={author}>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords} />
|
||||
<meta name="author" content={author} />
|
||||
|
||||
<!-- Page info, viewport, Astro credit -->
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Icons and colors -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Social media meta tags (Open Graphh + Twitter) -->
|
||||
<meta property="og:site_name" content="Awesome Privacy">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://awesome-privacy.xyz">
|
||||
<meta property="og:title" content={title}>
|
||||
<meta property="og:description" content={description}>
|
||||
<meta property="og:image" content="https://awesome-privacy.xyz/banner.png">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:url" content="https://awesome-privacy.xyz">
|
||||
<meta name="twitter:title" content={title}>
|
||||
<meta name="twitter:description" content={description}>
|
||||
<meta name="twitter:image" content="https://awesome-privacy.xyz/banner.png">
|
||||
<link rel="twitter:image" sizes="180x180" href="https://awesome-privacy.xyz/apple-touch-icon.png">
|
||||
<meta name="twitter:site" content="@Lissy_Sykes">
|
||||
<meta name="twitter:creator" content="@Lissy_Sykes">
|
||||
<meta property="og:site_name" content="Awesome Privacy" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://awesome-privacy.xyz" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://awesome-privacy.xyz/banner.png"
|
||||
/>
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:url" content="https://awesome-privacy.xyz" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content="https://awesome-privacy.xyz/banner.png"
|
||||
/>
|
||||
<link
|
||||
rel="twitter:image"
|
||||
sizes="180x180"
|
||||
href="https://awesome-privacy.xyz/apple-touch-icon.png"
|
||||
/>
|
||||
<meta name="twitter:site" content="@Lissy_Sykes" />
|
||||
<meta name="twitter:creator" content="@Lissy_Sykes" />
|
||||
|
||||
<!-- Non-tracking hit counter -->
|
||||
<script defer is:inline
|
||||
<script
|
||||
defer
|
||||
is:inline
|
||||
type="text/partytown"
|
||||
data-domain="awesome-privacy.xyz"
|
||||
src="https://no-track.as93.net/js/script.js">
|
||||
</script>
|
||||
src="https://no-track.as93.net/js/script.js"></script>
|
||||
|
||||
<!-- Schema.org markup for Google -->
|
||||
{breadcrumbs && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(makeBreadcrumbs())} />
|
||||
)}
|
||||
{customSchemaJson && (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(customSchemaJson)} />
|
||||
)}
|
||||
<script type="application/ld+json" set:html={JSON.stringify(makeSearchLd)} />
|
||||
{
|
||||
breadcrumbs && (
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(makeBreadcrumbs())}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
customSchemaJson && (
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(customSchemaJson)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(makeSearchLd)}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
{!hideNav && <NavBar /> }
|
||||
{!hideNav && <NavBar />}
|
||||
<slot />
|
||||
<Footer />
|
||||
</body>
|
||||
|
|
@ -124,11 +152,9 @@ const makeSearchLd = () => {
|
|||
<style is:global>
|
||||
@import '../styles/values.css';
|
||||
@import '../styles/typography.css';
|
||||
</style>
|
||||
|
||||
</style>
|
||||
|
||||
<style is:global>
|
||||
|
||||
html {
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
|
|
@ -142,5 +168,4 @@ const makeSearchLd = () => {
|
|||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,98 +1,91 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import Buton from '@components/form/Button.astro';
|
||||
|
||||
---
|
||||
|
||||
<Layout title="404 | Awesome Privacy">
|
||||
<article class="oh-crap">
|
||||
<h2>404</h2>
|
||||
<p class="what-happened">Page not found 😢</p>
|
||||
<p class="why-happened">
|
||||
It seems this page doesn't exist (yet). We're sorry about that.
|
||||
</p>
|
||||
<span class="back-you-go-then"><Buton url="/">Go back home</Buton></span>
|
||||
|
||||
<article class="oh-crap">
|
||||
<h2>404</h2>
|
||||
<p class="what-happened">Page not found 😢</p>
|
||||
<p class="why-happened">
|
||||
It seems this page doesn't exist (yet). We're sorry about that.
|
||||
</p>
|
||||
<span class="back-you-go-then"><Buton url="/">Go back home</Buton></span>
|
||||
|
||||
<nav class="other-places">
|
||||
Looking for somewhere else?
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/search">Search</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/browse">Browse</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about">About</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://as93.net">More Apps</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</article>
|
||||
|
||||
<nav class="other-places">
|
||||
Looking for somewhere else?
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/search">Search</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/browse">Browse</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about">About</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/lissy93/awesome-privacy">Source</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://as93.net">More Apps</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.oh-crap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 6rem);
|
||||
h2 {
|
||||
font-size: 12rem;
|
||||
margin: 0;
|
||||
}
|
||||
.what-happened {
|
||||
font-size: 4rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.why-happened {
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.back-you-go-then :global(a) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.oh-crap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 6rem);
|
||||
h2 {
|
||||
font-size: 12rem;
|
||||
margin: 0;
|
||||
}
|
||||
.what-happened {
|
||||
font-size: 4rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.why-happened {
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.back-you-go-then :global(a) {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
|
||||
.other-places {
|
||||
opacity: 0.8;
|
||||
margin: 2rem auto 1rem auto;
|
||||
text-align: center;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--accent);
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
li a {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.other-places {
|
||||
opacity: 0.8;
|
||||
margin: 2rem auto 1rem auto;
|
||||
text-align: center;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
li {
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--accent);
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
li a {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import SectionList from '@components/things/SectionList.astro';
|
||||
import Main from '@components/scafold/MainCard.astro';
|
||||
|
|
@ -29,129 +28,137 @@ export async function getStaticPaths() {
|
|||
});
|
||||
});
|
||||
|
||||
return pages.map(({ category, title, sections }) => {
|
||||
return {
|
||||
params: { category },
|
||||
props: { title, sections },
|
||||
};
|
||||
});
|
||||
return pages.map(({ category, title, sections }) => {
|
||||
return {
|
||||
params: { category },
|
||||
props: { title, sections },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const makeCarasolData = () => {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ItemList",
|
||||
"itemListElement": [
|
||||
sections.map((section, index) => ({
|
||||
"@type": "ListItem",
|
||||
"position": index + 1,
|
||||
"url": `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
|
||||
"item": {
|
||||
"@type": "Service",
|
||||
"name": section.name,
|
||||
"url": `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
|
||||
}
|
||||
})),
|
||||
]
|
||||
}
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
itemListElement: [
|
||||
sections.map((section, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
|
||||
item: {
|
||||
'@type': 'Service',
|
||||
name: section.name,
|
||||
url: `https://awesomeprivacy.com/${slugify(title)}/${slugify(section.name)}`,
|
||||
},
|
||||
})),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const makeBreadcrumbs = () => {
|
||||
return [
|
||||
{ name: 'Home', item: '/' },
|
||||
{ name: title, item: slugify(title) },
|
||||
];
|
||||
return [
|
||||
{ name: 'Home', item: '/' },
|
||||
{ name: title, item: slugify(title) },
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
const makePaginationLinks = () => {
|
||||
const index = categories.findIndex(category => category.name === title);
|
||||
const previousCategory = index > 0 ? categories[index - 1].name : null;
|
||||
const nextCategory = index < categories.length - 1 ? categories[index + 1].name : null;
|
||||
return { previous: previousCategory, next: nextCategory };
|
||||
const index = categories.findIndex((category) => category.name === title);
|
||||
const previousCategory = index > 0 ? categories[index - 1].name : null;
|
||||
const nextCategory =
|
||||
index < categories.length - 1 ? categories[index + 1].name : null;
|
||||
return { previous: previousCategory, next: nextCategory };
|
||||
};
|
||||
|
||||
const { previous, next } = makePaginationLinks();
|
||||
|
||||
---
|
||||
|
||||
<Layout title={`${title} | Awesome Privacy`} breadcrumbs={makeBreadcrumbs()} customSchemaJson={makeCarasolData()}>
|
||||
<Layout
|
||||
title={`${title} | Awesome Privacy`}
|
||||
breadcrumbs={makeBreadcrumbs()}
|
||||
customSchemaJson={makeCarasolData()}
|
||||
>
|
||||
<Main>
|
||||
<div class="breadcrumbs">
|
||||
<span>
|
||||
<a href="/">Awesome Privacy</a>
|
||||
➔ <a href={`/${slugify(title)}`}>{title}</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="breadcrumbs">
|
||||
<span>
|
||||
<a href="/">Awesome Privacy</a>
|
||||
➔ <a href={`/${slugify(title)}`}>{title}</a>
|
||||
</span>
|
||||
</div>
|
||||
<SectionList title={title} sections={sections} bigTitle={true} />
|
||||
Or, <a href={`/all#${slugify(title)}`}>Browse All {title}</a>
|
||||
Or, <a href={`/all#${slugify(title)}`}>Browse All {title}</a>
|
||||
<div class="pagination-navigation">
|
||||
{ previous ? (
|
||||
<Button url={`/${slugify(previous)}`}>
|
||||
<span>← Previous</span>
|
||||
<p>{previous}</p>
|
||||
</Button>
|
||||
) : <p class="nothing"></p>}
|
||||
{ next && (
|
||||
<Button url={`/${slugify(next)}`}>
|
||||
<span>Next →</span>
|
||||
<p>{next}</p>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
previous ? (
|
||||
<Button url={`/${slugify(previous)}`}>
|
||||
<span>← Previous</span>
|
||||
<p>{previous}</p>
|
||||
</Button>
|
||||
) : (
|
||||
<p class="nothing" />
|
||||
)
|
||||
}
|
||||
{
|
||||
next && (
|
||||
<Button url={`/${slugify(next)}`}>
|
||||
<span>Next →</span>
|
||||
<p>{next}</p>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Main>
|
||||
</Layout>
|
||||
|
||||
<style lang="scss">
|
||||
.pagination-navigation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
:global(.button) {
|
||||
min-width: 120px;
|
||||
width: fit-content;
|
||||
padding: 0.25rem 1rem;
|
||||
text-align: right;
|
||||
&:first-child { text-align: left; }
|
||||
p {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
}
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
}
|
||||
.nothing {
|
||||
width: 120px;
|
||||
}
|
||||
.go-to-category {
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.pagination-navigation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
:global(.button) {
|
||||
min-width: 120px;
|
||||
width: fit-content;
|
||||
padding: 0.25rem 1rem;
|
||||
text-align: right;
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
}
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
.nothing {
|
||||
width: 120px;
|
||||
}
|
||||
.go-to-category {
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
.breadcrumbs {
|
||||
opacity: 0.5;
|
||||
font-size: 0.8rem;
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
transition: all 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
@media(max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,359 +1,478 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import Main from '@components/scafold/MainCard.astro';
|
||||
import Icon from '@components/form/Icon.astro';
|
||||
import SocialShare from '@components/form/Social.astro';
|
||||
import { parseMarkdown } from '@utils/parse-markdown';
|
||||
import { authorProjects, authorSocials, aboutOurData, projectRequirements, appDescription } from '../site-config';
|
||||
import {
|
||||
authorProjects,
|
||||
authorSocials,
|
||||
aboutOurData,
|
||||
projectRequirements,
|
||||
appDescription,
|
||||
} from '../site-config';
|
||||
|
||||
const contributorsResource = async () => {
|
||||
const url = 'https://api.github.com/repos/lissy93/personal-security-checklist/contributors?per_page=100';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch contributors');
|
||||
}
|
||||
interface GitHubContributor {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
}
|
||||
|
||||
interface Sponsor {
|
||||
login: string;
|
||||
avatarUrl: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const githubHeaders: Record<string, string> = {
|
||||
'User-Agent': 'awesome-privacy',
|
||||
};
|
||||
const apiKey = import.meta.env.GITHUB_API_KEY;
|
||||
if (apiKey) {
|
||||
githubHeaders['Authorization'] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const contributorsResource = async (): Promise<GitHubContributor[] | null> => {
|
||||
const url =
|
||||
'https://api.github.com/repos/lissy93/personal-security-checklist/contributors?per_page=100';
|
||||
const response = await fetch(url, { headers: githubHeaders });
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const sponsorsResource = async () => {
|
||||
const sponsorsResource = async (): Promise<Sponsor[] | null> => {
|
||||
const url = 'https://github-sponsors.as93.workers.dev/lissy93';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sponsors');
|
||||
}
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const contributors = await contributorsResource();
|
||||
const sponsors = await sponsorsResource();
|
||||
|
||||
const licenseContent = async () => {
|
||||
const url = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/HEAD/LICENSE';
|
||||
const url =
|
||||
'https://raw.githubusercontent.com/Lissy93/awesome-privacy/HEAD/LICENSE';
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch license');
|
||||
}
|
||||
return await response.text();
|
||||
};
|
||||
|
||||
---
|
||||
|
||||
<Layout title="About | Awesome Privacy" description={appDescription}>
|
||||
<div class="about-page">
|
||||
<Main>
|
||||
<h2 id="objective">Objective</h2>
|
||||
<p>
|
||||
Large data-hungry corporations dominate the digital world but with little,
|
||||
or no respect for your privacy.
|
||||
We believe that privacy is a fundamental human right and that it should be protected.
|
||||
<br /><br />
|
||||
Migrating to open-source applications and those with a strong emphasis on
|
||||
privacy & security will help safegaurd you from corporations,
|
||||
governments, and hackers who log, store or sell your sensetive personal data.
|
||||
<br /><br />
|
||||
Awesome Privacy is a directory of alternative software which respects your privacy.
|
||||
<br /><br />
|
||||
|
||||
</p>
|
||||
<hr />
|
||||
<Main>
|
||||
<h2 id="objective">Objective</h2>
|
||||
<p>
|
||||
Large data-hungry corporations dominate the digital world but with
|
||||
little, or no respect for your privacy. We believe that privacy is a
|
||||
fundamental human right and that it should be protected.
|
||||
<br /><br />
|
||||
Migrating to open-source applications and those with a strong emphasis on
|
||||
privacy & security will help safegaurd you from corporations, governments,
|
||||
and hackers who log, store or sell your sensetive personal data.
|
||||
<br /><br />
|
||||
Awesome Privacy is a directory of alternative software which respects your
|
||||
privacy.
|
||||
<br /><br />
|
||||
</p>
|
||||
<hr />
|
||||
|
||||
<h2 id="creteria">Software Requirements</h2>
|
||||
<div class="software-requirements"><p set:html={parseMarkdown(projectRequirements)}></p></div>
|
||||
<hr />
|
||||
<h2 id="creteria">Software Requirements</h2>
|
||||
<div class="software-requirements">
|
||||
<p set:html={parseMarkdown(projectRequirements)} />
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<h2 id="our-data">Our Data</h2>
|
||||
<div class="about-the-data"><p set:html={parseMarkdown(aboutOurData)}></p></div>
|
||||
<hr />
|
||||
<h2 id="our-data">Our Data</h2>
|
||||
<div class="about-the-data">
|
||||
<p set:html={parseMarkdown(aboutOurData)} />
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<h2 id="contributing">Contributing</h2>
|
||||
<p>
|
||||
Awesome Privacy (including all data and code) is fully open source,
|
||||
maintained by a core group of volenteers, with a lot of help from the community.
|
||||
<br /><br />
|
||||
We welcome suggestions, additions, edits and removals to the list.<br />
|
||||
It's thanks to contributors like you that this project is possible 💜
|
||||
<br /><br />
|
||||
To get started, head over to our <a href="https://github.com/lissy93/awesome-privacy">GitHub repository</a>.
|
||||
From here, you'll be able to edit <a href="#"><code>awesome-privacy.yml</code></a>
|
||||
where all data is stored, and then submit your changes as a pull request.
|
||||
<br /><br />
|
||||
If you're new to open source, you can find some resources to get you started
|
||||
at <a href="https://git-in.to">git-in.to</a>, but feel free to reach out if you need any help 😊
|
||||
</p>
|
||||
<hr />
|
||||
<h2 id="contributing">Contributing</h2>
|
||||
<p>
|
||||
Awesome Privacy (including all data and code) is fully open source,
|
||||
maintained by a core group of volenteers, with a lot of help from the
|
||||
community.
|
||||
<br /><br />
|
||||
We welcome suggestions, additions, edits and removals to the list.<br />
|
||||
It's thanks to contributors like you that this project is possible 💜
|
||||
<br /><br />
|
||||
To get started, head over to our <a
|
||||
href="https://github.com/lissy93/awesome-privacy">GitHub repository</a
|
||||
>. From here, you'll be able to edit <a href="#"
|
||||
><code>awesome-privacy.yml</code></a
|
||||
>
|
||||
where all data is stored, and then submit your changes as a pull request.
|
||||
<br /><br />
|
||||
If you're new to open source, you can find some resources to get you started
|
||||
at <a href="https://git-in.to">git-in.to</a>, but feel free to reach out
|
||||
if you need any help 😊
|
||||
</p>
|
||||
<hr />
|
||||
|
||||
<h2 id="acknowledgements">Acknowledgements</h2>
|
||||
<h3 id="sponsors">Sponsors</h3>
|
||||
<p>Huge thanks to the following sponsors, for their ongoing support 💖</p>
|
||||
<div class="user-list">
|
||||
{sponsorsResource().then((sponsors) => {
|
||||
return sponsors.map((sponsor: any) => (
|
||||
<a class="user" href={`https://github.com/${sponsor.login}`} target="_blank" rel="noreferrer">
|
||||
<img src={sponsor.avatarUrl} alt={sponsor.login} />
|
||||
<p>{sponsor.name || sponsor.login}</p>
|
||||
</a>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
<h2 id="acknowledgements">Acknowledgements</h2>
|
||||
<h3 id="sponsors">Sponsors</h3>
|
||||
<p>Huge thanks to the following sponsors, for their ongoing support 💖</p>
|
||||
{
|
||||
sponsors ? (
|
||||
<div class="user-list">
|
||||
{sponsors.map((sponsor) => (
|
||||
<a
|
||||
class="user"
|
||||
href={`https://github.com/${sponsor.login}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={sponsor.avatarUrl} alt={sponsor.login} />
|
||||
<p>{sponsor.name || sponsor.login}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
class="fallback-img"
|
||||
src="https://readme-contribs.as93.net/sponsors/lissy93?perRow=12&shape=squircle&textColor=ffffff&limit=96"
|
||||
alt="Sponsors"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<h3 id="contributors">Contributors</h3>
|
||||
<p>
|
||||
This project exists thanks to all the people who've helped build and maintain it.<br />
|
||||
Special thanks to the below, top-100 contributors 🌟
|
||||
</p>
|
||||
<div class="user-list">
|
||||
{contributorsResource().then((sponsors) => {
|
||||
return sponsors.map((sponsor: any) => (
|
||||
<a class="user" href={sponsor.html_url} target="_blank" rel="noreferrer">
|
||||
<img src={sponsor.avatar_url} alt={sponsor.login} />
|
||||
<p>{sponsor.login}</p>
|
||||
</a>
|
||||
));
|
||||
})}
|
||||
</div>
|
||||
<h3 id="contributors">Contributors</h3>
|
||||
<p>
|
||||
This project exists thanks to all the people who've helped build and
|
||||
maintain it.<br />
|
||||
Special thanks to the below, top-100 contributors 🌟
|
||||
</p>
|
||||
{
|
||||
contributors ? (
|
||||
<div class="user-list">
|
||||
{contributors.map((contributor) => (
|
||||
<a
|
||||
class="user"
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img src={contributor.avatar_url} alt={contributor.login} />
|
||||
<p>{contributor.login}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
class="fallback-img"
|
||||
src="https://readme-contribs.as93.net/contributors/lissy93/awesome-privacy?perRow=12&shape=squircle&textColor=ffffff&limit=96"
|
||||
alt="Contributors"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<h2 id="support-us">Help us out</h2>
|
||||
<p>
|
||||
Awesome Privacy is a free, open source and community-maintained resource.<br />
|
||||
There's a few ways you can support us:
|
||||
<ul class="help-list">
|
||||
<li>Visiting, forking, or starring or our <a href="https://github.com/lissy93/awesome-privacy">GitHub repository</a></li>
|
||||
<li>Help us keep our info up-to-date, but submitting an addition, removal or ammendment</li>
|
||||
<li>Leave feedback on services that you've used, to help others make a more informed decission</li>
|
||||
<li>Consider <a href="https://github.com/sponsors/Lissy93">sponsoring us on GitHub</a>, if you're able 💖</li>
|
||||
<li>Share Awesome Privacy with your network</li>
|
||||
</ul>
|
||||
<SocialShare />
|
||||
</p>
|
||||
<h2 id="support-us">Help us out</h2>
|
||||
<p>
|
||||
Awesome Privacy is a free, open source and community-maintained
|
||||
resource.<br />
|
||||
There's a few ways you can support us:
|
||||
<ul class="help-list">
|
||||
<li>
|
||||
Visiting, forking, or starring or our <a
|
||||
href="https://github.com/lissy93/awesome-privacy"
|
||||
>GitHub repository</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Help us keep our info up-to-date, but submitting an addition,
|
||||
removal or ammendment
|
||||
</li>
|
||||
<li>
|
||||
Leave feedback on services that you've used, to help others make a
|
||||
more informed decission
|
||||
</li>
|
||||
<li>
|
||||
Consider <a href="https://github.com/sponsors/Lissy93"
|
||||
>sponsoring us on GitHub</a
|
||||
>, if you're able 💖
|
||||
</li>
|
||||
<li>Share Awesome Privacy with your network</li>
|
||||
</ul>
|
||||
<SocialShare />
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<h2 id="author">Author</h2>
|
||||
<article class="author">
|
||||
<h2 id="author">Author</h2>
|
||||
<article class="author">
|
||||
<p>
|
||||
This project was originally started by
|
||||
me, <a href="https://aliciasykes.com">Alicia Sykes</a>
|
||||
This project was originally started by me, <a
|
||||
href="https://aliciasykes.com">Alicia Sykes</a
|
||||
>
|
||||
(with a lot of help from the community)
|
||||
</p>
|
||||
<br />
|
||||
<p class="about">
|
||||
I build apps which aim to help people escape big tech, secure their data,
|
||||
and protect their privacy.
|
||||
I build apps which aim to help people escape big tech, secure their
|
||||
data, and protect their privacy.
|
||||
</p>
|
||||
<br />
|
||||
<div class="pic-and-socials">
|
||||
<a href="https://aliciasykes.com">
|
||||
<img class="pic" width="180" height="240" alt="Alicia Sykes" src="https://i.ibb.co/fq10qhL/DSC-0597.jpg" />
|
||||
<img
|
||||
class="pic"
|
||||
width="180"
|
||||
height="240"
|
||||
alt="Alicia Sykes"
|
||||
src="https://cdn.as93.net/profile-pictures/dsc_0597"
|
||||
/>
|
||||
</a>
|
||||
<div class="socials">
|
||||
{
|
||||
authorSocials.map((social: { link: string; color: string; icon: string; }) => (
|
||||
<a href={social.link} style={`--color: ${social.color}`} target="_blank">
|
||||
<Icon icon={social.icon} width={24} height={24} />
|
||||
</a>
|
||||
))
|
||||
authorSocials.map(
|
||||
(social: { link: string; color: string; icon: string }) => (
|
||||
<a
|
||||
href={social.link}
|
||||
style={`--color: ${social.color}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon icon={social.icon} width={24} height={24} />
|
||||
</a>
|
||||
),
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<p class="more-about">
|
||||
I have a particular interest in self-hosting, Linux, security and OSINT.<br />
|
||||
I have a particular interest in self-hosting, Linux, security and
|
||||
OSINT.<br />
|
||||
So if this type of stuff interests you, check out these other projects:
|
||||
</p>
|
||||
<ul>
|
||||
{
|
||||
authorProjects.map((project: { title: string; icon: string; link: string; description: string; }) => (
|
||||
<li>
|
||||
<img width="20" height="20" alt={project.title} src={project.icon} />
|
||||
<a href={project.link} target="_blank" rel="noreferrer">
|
||||
{project.title}
|
||||
</a> - {project.description}
|
||||
</li>
|
||||
))
|
||||
authorProjects.map(
|
||||
(project: {
|
||||
title: string;
|
||||
icon: string;
|
||||
link: string;
|
||||
description: string;
|
||||
}) => (
|
||||
<li>
|
||||
<img
|
||||
width="20"
|
||||
height="20"
|
||||
alt={project.title}
|
||||
src={project.icon}
|
||||
/>
|
||||
<a href={project.link} target="_blank" rel="noreferrer">
|
||||
{project.title}
|
||||
</a>{' '}
|
||||
- {project.description}
|
||||
</li>
|
||||
),
|
||||
)
|
||||
}
|
||||
<li>
|
||||
...and loads more, over at <a href="https://apps.aliciasykes.com/">apps.aliciasykes.com</a>
|
||||
...and loads more, over at <a href="https://apps.aliciasykes.com/"
|
||||
>apps.aliciasykes.com</a
|
||||
>
|
||||
and on <a href="https://github.com/lissy93">github.com/lissy93</a>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>
|
||||
If you'd like to support the ongoing work on Awesome Privacy,
|
||||
and other similar projects,
|
||||
consider <a href="https://github.com/sponsors/lissy93">sponsoring me</a> on GitHub 💖
|
||||
If you'd like to support the ongoing work on Awesome Privacy, and
|
||||
other similar projects, consider <a
|
||||
href="https://github.com/sponsors/lissy93">sponsoring me</a
|
||||
> on GitHub 💖
|
||||
</p>
|
||||
</article>
|
||||
|
||||
</article>
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<h2 id="license">License</h2>
|
||||
<p>
|
||||
All content on Awesome Privacy is freely available, within the public domain,
|
||||
licensed under Creative Commons Zero v1.0 Universal.
|
||||
The code for the website is licensed under MIT.
|
||||
</p>
|
||||
<p>
|
||||
{licenseContent().then((license) => {
|
||||
return (<pre class="license-content">{license}</pre>)
|
||||
})}
|
||||
</p>
|
||||
|
||||
</Main>
|
||||
<h2 id="license">License</h2>
|
||||
<p>
|
||||
All content on Awesome Privacy is freely available, within the public
|
||||
domain, licensed under Creative Commons Zero v1.0 Universal. The code
|
||||
for the website is licensed under MIT.
|
||||
</p>
|
||||
<p>
|
||||
{
|
||||
licenseContent().then((license) => {
|
||||
return <pre class="license-content">{license}</pre>;
|
||||
})
|
||||
}
|
||||
</p>
|
||||
</Main>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style lang="scss">
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
margin: 2rem 0 1rem 0;
|
||||
}
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
hr {
|
||||
margin: 2rem 0;
|
||||
border: 0;
|
||||
border-top: 2px solid var(--accent);
|
||||
&:nth-child(6) {
|
||||
border-color: var(--accent-3);
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
&:nth-child(9) {
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.software-requirements, .about-the-data {
|
||||
:global(p) {
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
:global(ul) {
|
||||
padding-left: 0.5rem;
|
||||
list-style: none;
|
||||
font-size: 1.2rem;
|
||||
:global(li ul) {
|
||||
padding: 0 0 0.5rem 1rem;
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
}
|
||||
:global(h3) {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:global(strong) {
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(small) {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
:global(a) {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
:global(code) {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background: #acabb782;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: var(--curve-sm);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
.user {
|
||||
width: 6rem;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
img {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: var(--curve-md);
|
||||
}
|
||||
p {
|
||||
font-size: 1rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.license-content {
|
||||
max-height: 500px;
|
||||
overflow: scroll;
|
||||
background: var(--background-form);
|
||||
width: fit-content;
|
||||
border-radius: var(--curve-sm);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-family: mono;
|
||||
max-width: 100vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.help-list {
|
||||
padding-left: 1rem;
|
||||
list-style: circle;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.author {
|
||||
p {
|
||||
margin: 1rem 0 0 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.5rem;
|
||||
hr {
|
||||
margin: 2rem 0;
|
||||
border: 0;
|
||||
border-top: 2px solid var(--accent);
|
||||
&:nth-child(6) {
|
||||
border-color: var(--accent-3);
|
||||
}
|
||||
&:nth-child(9) {
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.software-requirements,
|
||||
.about-the-data {
|
||||
:global(p) {
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
:global(ul) {
|
||||
padding-left: 0.5rem;
|
||||
list-style: none;
|
||||
font-size: 1.2rem;
|
||||
:global(li ul) {
|
||||
padding: 0 0 0.5rem 1rem;
|
||||
list-style: circle;
|
||||
}
|
||||
}
|
||||
:global(h3) {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:global(strong) {
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(small) {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
:global(a) {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
:global(code) {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
background: #acabb782;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: var(--curve-sm);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin: 2rem 0;
|
||||
.user {
|
||||
width: 6rem;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--curve-sm);
|
||||
transition: background 0.2s ease-in-out;
|
||||
&:hover {
|
||||
background: var(--background-form);
|
||||
}
|
||||
img {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: var(--curve-md);
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem auto 0 auto;
|
||||
font-size: 1rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fallback-img {
|
||||
max-width: 100%;
|
||||
margin: 2rem 0;
|
||||
border-radius: var(--curve-sm);
|
||||
}
|
||||
|
||||
.license-content {
|
||||
max-height: 500px;
|
||||
overflow: scroll;
|
||||
background: var(--background-form);
|
||||
width: fit-content;
|
||||
border-radius: var(--curve-sm);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-family: 'Lekton', monospace;
|
||||
max-width: 100vw;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.help-list {
|
||||
padding-left: 1rem;
|
||||
list-style: circle;
|
||||
margin-bottom: 0;
|
||||
li {
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.author {
|
||||
p {
|
||||
margin: 1rem 0 0 0;
|
||||
}
|
||||
}
|
||||
.about {
|
||||
font-size: 1.4rem;
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
color: var(--accent-3);
|
||||
text-align: center;
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.pic-and-socials {
|
||||
float:right;
|
||||
img {
|
||||
margin: 0.5rem 1rem;
|
||||
border-radius: var(--curve-md);
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
ul {
|
||||
padding-left: 1.5rem;
|
||||
list-style: circle;
|
||||
margin-bottom: 0;
|
||||
li {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
.socials {
|
||||
display: flex;
|
||||
margin: 0.5rem 1rem;
|
||||
justify-content: space-between;
|
||||
:global(a svg) {
|
||||
color: var(--foreground);
|
||||
transition: color 0.2s ease-in-out;
|
||||
&:hover {
|
||||
color: var(--color);
|
||||
.about {
|
||||
font-size: 1.4rem;
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
color: var(--accent-3);
|
||||
text-align: center;
|
||||
max-width: 550px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.pic-and-socials {
|
||||
float: right;
|
||||
img {
|
||||
margin: 0.5rem 1rem;
|
||||
border-radius: var(--curve-md);
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
.socials {
|
||||
display: flex;
|
||||
margin: 0.5rem 1rem;
|
||||
justify-content: space-between;
|
||||
:global(a svg) {
|
||||
color: var(--foreground);
|
||||
transition: color 0.2s ease-in-out;
|
||||
&:hover {
|
||||
color: var(--color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,19 +3,22 @@ import yaml from 'js-yaml';
|
|||
|
||||
import type { AwesomePrivacy } from '../../types/Service';
|
||||
|
||||
const awesomePrivacyYamlPath = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
|
||||
const awesomePrivacyYamlPath =
|
||||
'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
|
||||
const yamlContent = await fetch(awesomePrivacyYamlPath)
|
||||
.then(response => response.text())
|
||||
.catch(error => {
|
||||
return JSON.stringify({ error: "Failed to fetch YAML file", details: error });
|
||||
});
|
||||
.then((response) => response.text())
|
||||
.catch((error) => {
|
||||
return JSON.stringify({
|
||||
error: 'Failed to fetch YAML file',
|
||||
details: error,
|
||||
});
|
||||
});
|
||||
|
||||
const yamlObject = yaml.load(yamlContent) as AwesomePrivacy;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(yamlObject), { headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
return new Response(JSON.stringify(yamlObject), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,43 +3,45 @@ import Layout from '@layouts/Layout.astro';
|
|||
import Button from '@components/form/Button.astro';
|
||||
|
||||
import MainCard from '@components/scafold/MainCard.astro';
|
||||
|
||||
---
|
||||
|
||||
<Layout title="API Docs | Awesome Privacy">
|
||||
|
||||
<MainCard>
|
||||
<div id="swagger-ui"></div>
|
||||
<div class="go">
|
||||
<Button url="https://api.awesome-privacy.xyz" text="Try it Out" />
|
||||
</div>
|
||||
</MainCard>
|
||||
|
||||
<MainCard>
|
||||
<div id="swagger-ui"></div>
|
||||
<div class="go">
|
||||
<Button url="https://api.awesome-privacy.xyz" text="Try it Out" />
|
||||
</div>
|
||||
</MainCard>
|
||||
</Layout>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui.css">
|
||||
<script is:inline src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui-bundle.js"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui.css"
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.1.3/swagger-ui-bundle.js"
|
||||
></script>
|
||||
<script type="module" is:inline>
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
url: 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/api/open-api-spec.yml',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
]
|
||||
});
|
||||
}
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
url: 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/api/open-api-spec.yml',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset,
|
||||
],
|
||||
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.go {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.go {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -14,35 +14,47 @@ interface LineNumberData {
|
|||
[service: string]: {
|
||||
lineNumbers: LineNumberRange | null;
|
||||
yaml: string;
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const awesomePrivacyYamlPath = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
|
||||
const awesomePrivacyYamlPath =
|
||||
'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
|
||||
|
||||
/**
|
||||
* Given a service object and an array of string lines from the raw YAML
|
||||
* Find the starting and ending line number for that service
|
||||
*/
|
||||
const calculateServiceRange = (service: Service, category: Category, yamlLines: string[]): LineNumberRange | null => {
|
||||
const calculateServiceRange = (
|
||||
service: Service,
|
||||
category: Category,
|
||||
yamlLines: string[],
|
||||
): LineNumberRange | null => {
|
||||
const lookFor = `- name: ${service.name}`;
|
||||
const categoryStart = yamlLines.findIndex(line => line.includes(category.name));
|
||||
const start = yamlLines.slice(categoryStart).findIndex(line => line.includes(lookFor)) + categoryStart + 1;
|
||||
const categoryStart = yamlLines.findIndex((line) =>
|
||||
line.includes(category.name),
|
||||
);
|
||||
const start =
|
||||
yamlLines.slice(categoryStart).findIndex((line) => line.includes(lookFor)) +
|
||||
categoryStart +
|
||||
1;
|
||||
if (start === -1) return null;
|
||||
const detectEnd = (line: string) => {
|
||||
return line.trim().length === 0
|
||||
|| line.startsWith(' - ')
|
||||
|| line.includes('- name:')
|
||||
|| line.includes('notableMentions:')
|
||||
|| line.includes('furtherInfo:')
|
||||
|| line.includes('wordOfWarning:')
|
||||
}
|
||||
return (
|
||||
line.trim().length === 0 ||
|
||||
line.startsWith(' - ') ||
|
||||
line.includes('- name:') ||
|
||||
line.includes('notableMentions:') ||
|
||||
line.includes('furtherInfo:') ||
|
||||
line.includes('wordOfWarning:')
|
||||
);
|
||||
};
|
||||
const remainingLines = yamlLines.slice(start);
|
||||
const end = start + remainingLines.findIndex(detectEnd);
|
||||
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a service object, convert it into a correctly formatted YAML string
|
||||
|
|
@ -55,7 +67,10 @@ const convertJsonIntoYaml = (service: Service): string => {
|
|||
* Given the object representation of the YAML and the array of lines from the raw YAML
|
||||
* Organize the data into a format that can be returned as JSON
|
||||
*/
|
||||
const makeResults = (yamlObject: AwesomePrivacy, yamlLines: string[]): LineNumberData => {
|
||||
const makeResults = (
|
||||
yamlObject: AwesomePrivacy,
|
||||
yamlLines: string[],
|
||||
): LineNumberData => {
|
||||
const organizedData: LineNumberData = {};
|
||||
(yamlObject.categories || []).forEach((category) => {
|
||||
organizedData[category.name] = {};
|
||||
|
|
@ -70,16 +85,18 @@ const makeResults = (yamlObject: AwesomePrivacy, yamlLines: string[]): LineNumbe
|
|||
});
|
||||
});
|
||||
return organizedData;
|
||||
}
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
|
||||
// Fetch the raw YAML from the awesome-privacy repository
|
||||
const yamlContent = await fetch(awesomePrivacyYamlPath)
|
||||
.then(response => response.text())
|
||||
.catch(error => {
|
||||
return JSON.stringify({ error: "Failed to fetch YAML file", details: error });
|
||||
});
|
||||
.then((response) => response.text())
|
||||
.catch((error) => {
|
||||
return JSON.stringify({
|
||||
error: 'Failed to fetch YAML file',
|
||||
details: error,
|
||||
});
|
||||
});
|
||||
|
||||
// Array of lines from the raw YAML
|
||||
const yamlLines: string[] = yamlContent.split('\n');
|
||||
|
|
@ -90,7 +107,7 @@ export const GET: APIRoute = async () => {
|
|||
// Make results
|
||||
const results = makeResults(yamlObject, yamlLines);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(results), { headers: { 'content-type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
return new Response(JSON.stringify(results), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import SectionList from '@components/things/SectionList.astro';
|
||||
|
||||
|
|
@ -7,210 +6,221 @@ import { fetchData } from '@utils/fetch-data';
|
|||
import type { Category } from '../types/Service';
|
||||
|
||||
const categories: Category[] = (await fetchData())?.categories;
|
||||
|
||||
---
|
||||
|
||||
<Layout title="Browse">
|
||||
<div class="head-wrap">
|
||||
<h2>Browse</h2>
|
||||
<span>
|
||||
<input
|
||||
id="searchInput"
|
||||
type="search"
|
||||
placeholder="Start typing to filter..."
|
||||
/>
|
||||
<p id="press-enter-msg" class="press-enter">
|
||||
Press enter for deep search
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
<p class="sitemap-link">
|
||||
Not sure what you're looking for? Take a look through <a href="/all"
|
||||
>all listings</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="head-wrap">
|
||||
<h2>Browse</h2>
|
||||
<span>
|
||||
<input id="searchInput" type="search" placeholder="Start typing to filter..." />
|
||||
<p id="press-enter-msg" class="press-enter">Press enter for deep search</p>
|
||||
</span>
|
||||
</div>
|
||||
<p class="sitemap-link">Not sure what you're looking for? Take a look through <a href="/all">all listings</a></p>
|
||||
<ul class="categories">
|
||||
{
|
||||
categories.map((category) => (
|
||||
<li class="category">
|
||||
<SectionList title={category.name} sections={category.sections} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<ul class="categories">
|
||||
{categories.map((category) => (
|
||||
<li class="category">
|
||||
<SectionList title={category.name} sections={category.sections} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="no-results">
|
||||
<p class="zilch">Nothing found 😢</p>
|
||||
<p>Try a <a href="/search">deep search</a> instead</p>
|
||||
</div>
|
||||
<div class="no-results">
|
||||
<p class="zilch">Nothing found 😢</p>
|
||||
<p>Try a <a href="/search">deep search</a> instead</p>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Time for some good ol' fashioned vanilla JS...
|
||||
// I reccomend you not to look at this code for too long, it's not pretty.
|
||||
const filterInput = document.querySelector('input');
|
||||
const categories = document.querySelectorAll<HTMLElement>('.category');
|
||||
const pressEnterMsg = document.getElementById('press-enter-msg') as HTMLElement | null;
|
||||
const noResults = document.querySelector('.no-results') as HTMLElement | null;
|
||||
// Time for some good ol' fashioned vanilla JS...
|
||||
// I reccomend you not to look at this code for too long, it's not pretty.
|
||||
const filterInput = document.querySelector('input');
|
||||
const categories = document.querySelectorAll<HTMLElement>('.category');
|
||||
const pressEnterMsg = document.getElementById(
|
||||
'press-enter-msg',
|
||||
) as HTMLElement | null;
|
||||
const noResults = document.querySelector('.no-results') as HTMLElement | null;
|
||||
|
||||
if (!pressEnterMsg || !noResults) {
|
||||
throw new Error('No pressEnterMsg or noResults');
|
||||
};
|
||||
if (!pressEnterMsg || !noResults) {
|
||||
throw new Error('No pressEnterMsg or noResults');
|
||||
}
|
||||
|
||||
filterInput?.addEventListener('input', (e) => {
|
||||
let resultsCount = 0;
|
||||
const filter = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
if (filter.length > 0) {
|
||||
pressEnterMsg.style.visibility = 'visible';
|
||||
} else {
|
||||
pressEnterMsg.style.visibility = 'hidden';
|
||||
}
|
||||
categories.forEach((category) => {
|
||||
const titleElement = category.querySelector('.category-title');
|
||||
const title = titleElement ? titleElement.textContent?.toLowerCase() : '';
|
||||
const sections = category.querySelectorAll<HTMLElement>('.section');
|
||||
let count = 0;
|
||||
sections.forEach((section) => {
|
||||
const sectionTitle = section.textContent?.toLowerCase();
|
||||
if (sectionTitle?.includes(filter)) {
|
||||
section.style.display = 'block';
|
||||
count++;
|
||||
resultsCount++;
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
});
|
||||
if (title && title.includes(filter)) {
|
||||
category.style.display = 'inline-flex';
|
||||
resultsCount++;
|
||||
} else if (count === 0) {
|
||||
category.style.display = 'none';
|
||||
}
|
||||
});
|
||||
noResults.style.display = resultsCount === 0 ? 'block' : 'none';
|
||||
});
|
||||
filterInput?.addEventListener('input', (e) => {
|
||||
let resultsCount = 0;
|
||||
const filter = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
if (filter.length > 0) {
|
||||
pressEnterMsg.style.visibility = 'visible';
|
||||
} else {
|
||||
pressEnterMsg.style.visibility = 'hidden';
|
||||
}
|
||||
categories.forEach((category) => {
|
||||
const titleElement = category.querySelector('.category-title');
|
||||
const title = titleElement ? titleElement.textContent?.toLowerCase() : '';
|
||||
const sections = category.querySelectorAll<HTMLElement>('.section');
|
||||
let count = 0;
|
||||
sections.forEach((section) => {
|
||||
const sectionTitle = section.textContent?.toLowerCase();
|
||||
if (sectionTitle?.includes(filter)) {
|
||||
section.style.display = 'block';
|
||||
count++;
|
||||
resultsCount++;
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
});
|
||||
if (title && title.includes(filter)) {
|
||||
category.style.display = 'inline-flex';
|
||||
resultsCount++;
|
||||
} else if (count === 0) {
|
||||
category.style.display = 'none';
|
||||
}
|
||||
});
|
||||
noResults.style.display = resultsCount === 0 ? 'block' : 'none';
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
|
||||
if (searchInput === null) return;
|
||||
searchInput.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
window.location.href = `/search/${encodeURIComponent(searchInput.value.trim())}`;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
searchInput.value = '';
|
||||
searchInput.blur();
|
||||
pressEnterMsg.style.visibility = 'hidden';
|
||||
categories.forEach((category) => {
|
||||
category.style.display = 'inline-flex';
|
||||
const sections = category.querySelectorAll<HTMLElement>('.section');
|
||||
sections.forEach((section) => {
|
||||
section.style.display = 'block';
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const searchInput = document.getElementById(
|
||||
'searchInput',
|
||||
) as HTMLInputElement | null;
|
||||
if (searchInput === null) return;
|
||||
searchInput.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
window.location.href = `/search/${encodeURIComponent(searchInput.value.trim())}`;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
searchInput.value = '';
|
||||
searchInput.blur();
|
||||
pressEnterMsg.style.visibility = 'hidden';
|
||||
categories.forEach((category) => {
|
||||
category.style.display = 'inline-flex';
|
||||
const sections = category.querySelectorAll<HTMLElement>('.section');
|
||||
sections.forEach((section) => {
|
||||
section.style.display = 'block';
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
.head-wrap {
|
||||
margin: 1rem auto 0 auto;
|
||||
width: 85vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
.press-enter {
|
||||
margin: 0.5rem 0 0 0;
|
||||
opacity: 0.5;
|
||||
visibility: hidden;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
color: var(--accent-3);
|
||||
background: var(--background-form);
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
|
||||
.head-wrap {
|
||||
margin: 1rem auto 0 auto;
|
||||
width: 85vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
.press-enter {
|
||||
margin: 0.5rem 0 0 0;
|
||||
opacity: 0.5;
|
||||
visibility: hidden;
|
||||
}
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
color: var(--accent-3);
|
||||
background: var(--background-form);
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
.sitemap-link {
|
||||
margin: 0 auto;
|
||||
width: 85vw;
|
||||
a {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
|
||||
.sitemap-link {
|
||||
margin: 0 auto;
|
||||
width: 85vw;
|
||||
a {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
width: 85vw;
|
||||
margin: 2rem auto 4rem auto;
|
||||
display: none;
|
||||
.zilch {
|
||||
font-size: 2rem;
|
||||
color: var(--accent-3);
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
}
|
||||
p {
|
||||
margin: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.categories {
|
||||
columns: 6 300px;
|
||||
column-gap: 1rem;
|
||||
margin: 0 auto;
|
||||
width: 85vw;
|
||||
max-width: 1800px;
|
||||
padding: 1rem 0 2rem 0;
|
||||
@media(max-width: 768px) {
|
||||
padding: 0;
|
||||
width: 95%;
|
||||
}
|
||||
.category {
|
||||
background: var(--accent-fg);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
padding: 1rem;
|
||||
width: 85%;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin: 1rem;
|
||||
.category-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
h3 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.sections {
|
||||
padding-left: 1rem;
|
||||
.section {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.service-count {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.no-results {
|
||||
text-align: center;
|
||||
width: 85vw;
|
||||
margin: 2rem auto 4rem auto;
|
||||
display: none;
|
||||
.zilch {
|
||||
font-size: 2rem;
|
||||
color: var(--accent-3);
|
||||
opacity: 0.5;
|
||||
font-weight: bold;
|
||||
}
|
||||
p {
|
||||
margin: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.categories {
|
||||
columns: 6 300px;
|
||||
column-gap: 1rem;
|
||||
margin: 0 auto;
|
||||
width: 85vw;
|
||||
max-width: 1800px;
|
||||
padding: 1rem 0 2rem 0;
|
||||
@media (max-width: 768px) {
|
||||
padding: 0;
|
||||
width: 95%;
|
||||
}
|
||||
.category {
|
||||
background: var(--accent-fg);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
padding: 1rem;
|
||||
width: 85%;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin: 1rem;
|
||||
.category-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
h3 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.sections {
|
||||
padding-left: 1rem;
|
||||
.section {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.service-count {
|
||||
color: var(--accent-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import Hero from '@components/Hero.astro';
|
||||
import Search from '@components/things/Search.svelte';
|
||||
|
|
@ -10,14 +9,14 @@ import { fetchData } from '@utils/fetch-data';
|
|||
import Button from '@components/form/Button.astro';
|
||||
import type { Category } from 'src/types/Service';
|
||||
|
||||
const categories = (await fetchData())?.categories || [] as Category[];
|
||||
|
||||
const description = 'Privacy is a fundamental human right; '
|
||||
+ 'without it, we\'re just open books in a world where everyone\'s '
|
||||
+ 'watching. Let\'s take control back.\n'
|
||||
+ 'Migrating open-source applications which do not collect, sell or log your data is a great first step.'
|
||||
+ 'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
|
||||
const categories = (await fetchData())?.categories || ([] as Category[]);
|
||||
|
||||
const description =
|
||||
'Privacy is a fundamental human right; ' +
|
||||
"without it, we're just open books in a world where everyone's " +
|
||||
"watching. Let's take control back.\n" +
|
||||
'Migrating open-source applications which do not collect, sell or log your data is a great first step.' +
|
||||
'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
|
||||
---
|
||||
|
||||
<Layout title="Home | Awesome Privacy" hideNav={true} description={description}>
|
||||
|
|
@ -29,11 +28,13 @@ const description = 'Privacy is a fundamental human right; '
|
|||
<Search client:visible data={categories} />
|
||||
<h2 class="browse-title"><a href="/browse">Browse</a></h2>
|
||||
<ul class="categories">
|
||||
{categories.map((category) => (
|
||||
<li class="category">
|
||||
<SectionList title={category.name} sections={category.sections} />
|
||||
</li>
|
||||
))}
|
||||
{
|
||||
categories.map((category) => (
|
||||
<li class="category">
|
||||
<SectionList title={category.name} sections={category.sections} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div class="view-all">
|
||||
<span>Or, just</span>
|
||||
|
|
@ -48,28 +49,33 @@ const description = 'Privacy is a fundamental human right; '
|
|||
</h2>
|
||||
<div class="about-summary">
|
||||
<p>
|
||||
Awesome Privacy is a collection of privacy-respecting services and tools.
|
||||
The aim is to help you escape big tech, and choose software that respects your privacy.
|
||||
Awesome Privacy is a collection of privacy-respecting services and
|
||||
tools. The aim is to help you escape big tech, and choose software that
|
||||
respects your privacy.
|
||||
</p>
|
||||
<p>
|
||||
Why? Because privacy is a fundamental human right; without it, we're just open books
|
||||
in a world where everyone's watching. Let's take control back.
|
||||
Why? Because privacy is a fundamental human right; without it, we're
|
||||
just open books in a world where everyone's watching. Let's take control
|
||||
back.
|
||||
</p>
|
||||
<p>
|
||||
Noticed something that should be added / removed / amended?
|
||||
We're a community-driven resource, so welcome contributions of any nature.
|
||||
All content and code is <a href="https://github.com/lissy93/awesome-privacy">open source</a>.
|
||||
Noticed something that should be added / removed / amended? We're a
|
||||
community-driven resource, so welcome contributions of any nature. All
|
||||
content and code is <a href="https://github.com/lissy93/awesome-privacy"
|
||||
>open source</a
|
||||
>.
|
||||
</p>
|
||||
<p>
|
||||
If you've found Awesome Privacy useful, help us out by sharing it with others,
|
||||
contributing, or consider <a href="https://github.com/sponsors/lissy93">sponsoring me</a> on GitHub.
|
||||
If you've found Awesome Privacy useful, help us out by sharing it with
|
||||
others, contributing, or consider <a
|
||||
href="https://github.com/sponsors/lissy93">sponsoring me</a
|
||||
> on GitHub.
|
||||
</p>
|
||||
</div>
|
||||
<div class="view-all">
|
||||
<span>Want to learn more?</span>
|
||||
<Button url="/about" text="Keep Reading..." />
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
|
|
@ -81,8 +87,8 @@ const description = 'Privacy is a fundamental human right; '
|
|||
max-width: calc(100% - 5rem);
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
@media(max-width: 768px) {
|
||||
padding: 0;
|
||||
@media (max-width: 768px) {
|
||||
padding: 0;
|
||||
}
|
||||
.view-all {
|
||||
text-align: center;
|
||||
|
|
@ -97,41 +103,43 @@ const description = 'Privacy is a fundamental human right; '
|
|||
h2 {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-3);
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
text-align: center;
|
||||
margin: 3rem 0 1rem 0;
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--accent-3);
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
position: relative;
|
||||
&:after {
|
||||
&:after {
|
||||
background: none repeat scroll 0 0 transparent;
|
||||
bottom: 0;
|
||||
content: "";
|
||||
content: '';
|
||||
display: block;
|
||||
height: 3px;
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
background: var(--accent);
|
||||
transition: width 0.3s ease 0s, left 0.3s ease 0s;
|
||||
transition:
|
||||
width 0.3s ease 0s,
|
||||
left 0.3s ease 0s;
|
||||
width: 0;
|
||||
}
|
||||
&:hover:after {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
&:hover:after {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about-summary {
|
||||
background: var(--accent-fg);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
padding: 1rem;
|
||||
width: 85%;
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
padding: 1rem;
|
||||
width: 85%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.categories {
|
||||
|
|
@ -148,7 +156,7 @@ const description = 'Privacy is a fundamental human right; '
|
|||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin: 1rem;
|
||||
@media(max-width: 768px) {
|
||||
@media (max-width: 768px) {
|
||||
margin: 1rem 0;
|
||||
width: 90%;
|
||||
}
|
||||
|
|
@ -157,7 +165,7 @@ const description = 'Privacy is a fundamental human right; '
|
|||
color: var(--foreground);
|
||||
}
|
||||
h3 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
|
|
@ -176,6 +184,4 @@ const description = 'Privacy is a fundamental human right; '
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import SavedServices from '@components/things/SavedServices.svelte';
|
||||
import GetSharableLink from '@components/things/GetSharableLink.svelte';
|
||||
|
||||
import { fetchData } from '@utils/fetch-data';
|
||||
import Button from '@components/form/Button.astro';
|
||||
import EditableTitle from '@components/form/EditableTitle.svelte';
|
||||
import type { Category } from '../../types/Service';
|
||||
|
||||
const categories = (await fetchData())?.categories || [] as Category[];
|
||||
const categories = (await fetchData())?.categories || ([] as Category[]);
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
|
|
@ -17,85 +14,92 @@ const inventoryId = Astro.params.inventoryId || 'Inventory';
|
|||
let cheekyLilError = '';
|
||||
|
||||
function makeTitle(input: string): string {
|
||||
return (input.includes('_') ? input : `mystry_${input}`)
|
||||
.split('_')[1]
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
return (input.includes('_') ? input : `mystry_${input}`)
|
||||
.split('_')[1]
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase());
|
||||
}
|
||||
|
||||
const serviceList = await fetch(`https://awesome-privacy-share-api.as93.net/${inventoryId}`).then((res) => res.json()) || [];
|
||||
const serviceList =
|
||||
(await fetch(
|
||||
`https://awesome-privacy-share-api.as93.net/${inventoryId}`,
|
||||
).then((res) => res.json())) || [];
|
||||
|
||||
if (serviceList.error) {
|
||||
cheekyLilError = serviceList.error;
|
||||
cheekyLilError = serviceList.error;
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<Layout title="Saved Services">
|
||||
<main>
|
||||
<h2>{makeTitle(inventoryId)}</h2>
|
||||
{cheekyLilError && (
|
||||
<div class="error">
|
||||
<p class="oh-deary-me">An error occoured</p>
|
||||
<p class="what-the-fuck-happened">{cheekyLilError}</p>
|
||||
<p class="what-next">
|
||||
We're sorry about that.<br />
|
||||
Try going <a href="/">back home</a>,
|
||||
or <a href="https://github.com/Lissy93/awesome-privacy/issues/new/choose">raising a ticket</a> on
|
||||
GitHub.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<SavedServices allData={categories} serviceList={serviceList} client:load />
|
||||
<div class="buttons">
|
||||
<p>Not found what you're looking for?</p>
|
||||
<Button url="/all">Browse Services</Button>
|
||||
</div>
|
||||
</main>
|
||||
<main>
|
||||
<h2>{makeTitle(inventoryId)}</h2>
|
||||
{
|
||||
cheekyLilError && (
|
||||
<div class="error">
|
||||
<p class="oh-deary-me">An error occoured</p>
|
||||
<p class="what-the-fuck-happened">{cheekyLilError}</p>
|
||||
<p class="what-next">
|
||||
We're sorry about that.
|
||||
<br />
|
||||
Try going <a href="/">back home</a>, or{' '}
|
||||
<a href="https://github.com/Lissy93/awesome-privacy/issues/new/choose">
|
||||
raising a ticket
|
||||
</a>{' '}
|
||||
on GitHub.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<SavedServices allData={categories} serviceList={serviceList} client:load />
|
||||
<div class="buttons">
|
||||
<p>Not found what you're looking for?</p>
|
||||
<Button url="/all">Browse Services</Button>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style lang="scss">
|
||||
main {
|
||||
margin: 0 auto 2rem auto;
|
||||
padding: 1rem;
|
||||
width: 1200px;
|
||||
max-width: calc(100% - 5rem);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 12rem);
|
||||
font-size: 1.25rem;
|
||||
h2 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
.buttons {
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.error {
|
||||
text-align: center;
|
||||
font-size: 1.4rem;
|
||||
.oh-deary-me {
|
||||
font-size: 1.8rem;
|
||||
margin: 0.2rem auto;
|
||||
}
|
||||
.what-the-fuck-happened {
|
||||
color: var(--danger);
|
||||
margin: 0.2rem auto;
|
||||
}
|
||||
.what-next {
|
||||
font-size: 1rem;
|
||||
margin-top: 3rem;
|
||||
opacity: 0.6;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
main {
|
||||
margin: 0 auto 2rem auto;
|
||||
padding: 1rem;
|
||||
width: 1200px;
|
||||
max-width: calc(100% - 5rem);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 12rem);
|
||||
font-size: 1.25rem;
|
||||
h2 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
.buttons {
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.error {
|
||||
text-align: center;
|
||||
font-size: 1.4rem;
|
||||
.oh-deary-me {
|
||||
font-size: 1.8rem;
|
||||
margin: 0.2rem auto;
|
||||
}
|
||||
.what-the-fuck-happened {
|
||||
color: var(--danger);
|
||||
margin: 0.2rem auto;
|
||||
}
|
||||
.what-next {
|
||||
font-size: 1rem;
|
||||
margin-top: 3rem;
|
||||
opacity: 0.6;
|
||||
a {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import SavedServices from '@components/things/SavedServices.svelte';
|
||||
import GetSharableLink from '@components/things/GetSharableLink.svelte';
|
||||
|
|
@ -9,53 +8,52 @@ import Button from '@components/form/Button.astro';
|
|||
import EditableTitle from '@components/form/EditableTitle.svelte';
|
||||
import type { Category } from '../../types/Service';
|
||||
|
||||
const categories = (await fetchData())?.categories || [] as Category[];
|
||||
|
||||
const categories = (await fetchData())?.categories || ([] as Category[]);
|
||||
---
|
||||
|
||||
<Layout title="Saved Services">
|
||||
<main>
|
||||
<div class="top-row">
|
||||
<!-- <h2>Inventory</h2> -->
|
||||
<EditableTitle client:load />
|
||||
<GetSharableLink client:load />
|
||||
</div>
|
||||
<SavedServices allData={categories} client:load />
|
||||
<div class="buttons">
|
||||
<p>Not found what you're looking for?</p>
|
||||
<Button url="/all">Browse Services</Button>
|
||||
</div>
|
||||
</main>
|
||||
<main>
|
||||
<div class="top-row">
|
||||
<!-- <h2>Inventory</h2> -->
|
||||
<EditableTitle client:load />
|
||||
<GetSharableLink client:load />
|
||||
</div>
|
||||
<SavedServices allData={categories} client:load />
|
||||
<div class="buttons">
|
||||
<p>Not found what you're looking for?</p>
|
||||
<Button url="/all">Browse Services</Button>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<style lang="scss">
|
||||
main {
|
||||
margin: 0 auto 2rem auto;
|
||||
padding: 1rem;
|
||||
width: 1200px;
|
||||
max-width: calc(100% - 5rem);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 12rem);
|
||||
font-size: 1.25rem;
|
||||
.top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h2 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
.buttons {
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
main {
|
||||
margin: 0 auto 2rem auto;
|
||||
padding: 1rem;
|
||||
width: 1200px;
|
||||
max-width: calc(100% - 5rem);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 12rem);
|
||||
font-size: 1.25rem;
|
||||
.top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h2 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
font-size: 3rem;
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
}
|
||||
.buttons {
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
---
|
||||
import Fuse from 'fuse.js';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import { fetchData, slugify } from '@utils/fetch-data';
|
||||
import { prepareSearchItems, searchOptions } from '@utils/do-searchy-searchy';
|
||||
import type { SearchItem } from '@utils/do-searchy-searchy';
|
||||
import Search from '@components/things/Search.svelte';
|
||||
import SmartSuggestions from '@components/things/SmartSuggestions.svelte';
|
||||
import FontAwesome from '@components/form/FontAwesome.svelte';
|
||||
|
||||
import type { Service } from '../../types/Service';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
let fuse: Fuse<any>;
|
||||
let fuse: Fuse<SearchItem>;
|
||||
|
||||
const categories = (await fetchData())?.categories;
|
||||
|
||||
|
|
@ -22,171 +21,187 @@ fuse = new Fuse(items, searchOptions);
|
|||
|
||||
const searchTerm = Astro.params.searchTerm;
|
||||
|
||||
const searchResults = fuse.search(searchTerm || '').map(result => result.item);
|
||||
const searchResults = fuse
|
||||
.search(searchTerm || '')
|
||||
.map((result) => result.item);
|
||||
|
||||
const services = searchResults.filter(result => result.type === 'Service');
|
||||
const services = searchResults.filter((result) => result.type === 'Service');
|
||||
|
||||
interface GroupedSection {
|
||||
sectionName: string;
|
||||
items: SearchItem[];
|
||||
}
|
||||
|
||||
interface GroupedCategory {
|
||||
categoryName: string;
|
||||
sections: Record<string, GroupedSection>;
|
||||
}
|
||||
|
||||
const putResultsIntoGroups = () => {
|
||||
const grouped = services.reduce((acc, item) => {
|
||||
const { category: categoryName, sectionName, ...service } = item;
|
||||
const grouped: Record<string, GroupedCategory> = {};
|
||||
|
||||
if (!acc[categoryName]) {
|
||||
acc[categoryName] = { categoryName, sections: {} };
|
||||
}
|
||||
for (const item of services) {
|
||||
const categoryName = item.category;
|
||||
const sectionName = item.sectionName || '';
|
||||
|
||||
if (!acc[categoryName].sections[sectionName]) {
|
||||
acc[categoryName].sections[sectionName] = { sectionName, items: [] };
|
||||
}
|
||||
if (!grouped[categoryName]) {
|
||||
grouped[categoryName] = { categoryName, sections: {} };
|
||||
}
|
||||
|
||||
acc[categoryName].sections[sectionName].items.push(service);
|
||||
if (!grouped[categoryName].sections[sectionName]) {
|
||||
grouped[categoryName].sections[sectionName] = { sectionName, items: [] };
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
grouped[categoryName].sections[sectionName].items.push(item);
|
||||
}
|
||||
|
||||
// Convert the grouped object into the desired array structure.
|
||||
// And fuck it, let's use `any`
|
||||
return Object.values(grouped).map((category: any) => ({
|
||||
categoryName: category.categoryName,
|
||||
sections: Object.values(category.sections)
|
||||
}));
|
||||
return Object.values(grouped).map((category) => ({
|
||||
categoryName: category.categoryName,
|
||||
sections: Object.values(category.sections),
|
||||
}));
|
||||
};
|
||||
|
||||
const beer = putResultsIntoGroups();
|
||||
|
||||
---
|
||||
|
||||
<Layout title="Search | Awesome Privacy">
|
||||
<section>
|
||||
<h1>Search</h1>
|
||||
<Search client:visible data={categories} previousSearch={searchTerm} />
|
||||
</section>
|
||||
<SmartSuggestions client:visible searchTerm={searchTerm || ''} categories={categories} />
|
||||
<section class="result-count">
|
||||
<h3>Deep Search</h3>
|
||||
<p>Showing {services.length} results for "{searchTerm}" sorted by relevence</p>
|
||||
</section>
|
||||
<div class="results">
|
||||
{
|
||||
beer.map((category: any) => (
|
||||
<div class="category">
|
||||
<a class="category-title" href={`/${slugify(category.categoryName)}`}>
|
||||
<h3>{category.categoryName}</h3>
|
||||
</a>
|
||||
<span class="section-icon"><FontAwesome iconName={slugify(category.categoryName)} /></span>
|
||||
<ul class="section-list">
|
||||
{category.sections.map((section: any) => (
|
||||
<li class="section-item">
|
||||
<h4>{section.sectionName}</h4>
|
||||
<ul class="service-list">
|
||||
{section.items.map((item: Service) => (
|
||||
<li>
|
||||
<a href={item.url}>{item.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<section>
|
||||
<h1>Search</h1>
|
||||
<Search client:visible data={categories} previousSearch={searchTerm} />
|
||||
</section>
|
||||
<SmartSuggestions
|
||||
client:visible
|
||||
searchTerm={searchTerm || ''}
|
||||
categories={categories}
|
||||
/>
|
||||
<section class="result-count">
|
||||
<h3>Deep Search</h3>
|
||||
<p>
|
||||
Showing {services.length} results for "{searchTerm}" sorted by relevence
|
||||
</p>
|
||||
</section>
|
||||
<div class="results">
|
||||
{
|
||||
beer.map((category) => (
|
||||
<div class="category">
|
||||
<a class="category-title" href={`/${slugify(category.categoryName)}`}>
|
||||
<h3>{category.categoryName}</h3>
|
||||
</a>
|
||||
<span class="section-icon">
|
||||
<FontAwesome iconName={slugify(category.categoryName)} />
|
||||
</span>
|
||||
<ul class="section-list">
|
||||
{category.sections.map((section) => (
|
||||
<li class="section-item">
|
||||
<h4>{section.sectionName}</h4>
|
||||
<ul class="service-list">
|
||||
{section.items.map((item) => (
|
||||
<li>
|
||||
<a href={item.url}>{item.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
max-width: 1100px;
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
max-width: 1100px;
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-3);
|
||||
font-family: 'Lekton', sans-serif;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-3);
|
||||
font-family: "Lekton", sans-serif;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
.result-count {
|
||||
padding: 0;
|
||||
width: 80vw;
|
||||
max-width: 900px;
|
||||
margin: 1rem auto 0 auto;
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.result-count {
|
||||
padding: 0;
|
||||
width: 80vw;
|
||||
max-width: 900px;
|
||||
margin: 1rem auto 0 auto;
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: var(--accent-3);
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
margin: 0 auto 2rem auto;
|
||||
padding: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
max-width: 1100px;
|
||||
columns: 6 300px;
|
||||
column-gap: 1rem;
|
||||
.category {
|
||||
padding: 1rem;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
width: 85%;
|
||||
margin: 1rem;
|
||||
position: relative;
|
||||
.category-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
h3 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
.section-icon {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
width: fit-content;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.5;
|
||||
text-shadow: 3px 3px 0 black;
|
||||
color: var(--accent-3);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
.section-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
}
|
||||
.service-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.results {
|
||||
margin: 0 auto 2rem auto;
|
||||
padding: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
max-width: 1100px;
|
||||
columns: 6 300px;
|
||||
column-gap: 1rem;
|
||||
.category {
|
||||
padding: 1rem;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
width: 85%;
|
||||
margin: 1rem;
|
||||
position: relative;
|
||||
.category-title {
|
||||
text-decoration: none;
|
||||
color: var(--foreground);
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
h3 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
.section-icon {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
width: fit-content;
|
||||
:global(svg) {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
opacity: 0.5;
|
||||
text-shadow: 3px 3px 0 black;
|
||||
color: var(--accent-3);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
.section-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
h4 {
|
||||
margin: 1rem 0 0 0;
|
||||
}
|
||||
.service-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,57 +1,55 @@
|
|||
---
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import { fetchData } from '@utils/fetch-data';
|
||||
|
||||
import Search from '@components/things/Search.svelte';
|
||||
|
||||
const categories = (await fetchData())?.categories;
|
||||
|
||||
---
|
||||
|
||||
<Layout title="Search | Awesome Privacy">
|
||||
<section>
|
||||
<h1>Search</h1>
|
||||
<Search client:visible data={categories} />
|
||||
<p class="sitemap-link">
|
||||
Browse <a href="/all">all listings</a>, or see all pages in the <a href="/sitemap">Sitemap</a>
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h1>Search</h1>
|
||||
<Search client:visible data={categories} />
|
||||
<p class="sitemap-link">
|
||||
Browse <a href="/all">all listings</a>, or see all pages in the <a
|
||||
href="/sitemap">Sitemap</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
</Layout>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
width: calc(100% - 5rem);
|
||||
max-width: 1100px;
|
||||
min-height: 80vh;
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
width: calc(100% - 5rem);
|
||||
max-width: 1100px;
|
||||
min-height: 80vh;
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-3);
|
||||
font-family: "Lekton", sans-serif;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.sitemap-link {
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
margin: 2rem auto;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-3);
|
||||
font-family: 'Lekton', sans-serif;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.sitemap-link {
|
||||
opacity: 0.6;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
margin: 2rem auto;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,234 +1,244 @@
|
|||
---
|
||||
|
||||
|
||||
import Layout from '@layouts/Layout.astro';
|
||||
import type { AwesomePrivacy } from '../types/Service';
|
||||
import { fetchData, slugify } from '@utils/fetch-data';
|
||||
|
||||
const categories = (await fetchData() as AwesomePrivacy)?.categories || [];
|
||||
|
||||
const categories = ((await fetchData()) as AwesomePrivacy)?.categories || [];
|
||||
---
|
||||
|
||||
<Layout title="All Links | Awesome Privacy">
|
||||
<main>
|
||||
<h2>Sitemap</h2>
|
||||
<p>
|
||||
Below is a full listing of all pages on this site.<br>
|
||||
<small>As reflected in our <a href="/sitemap-index.xml">sitemap.xml</a></small>
|
||||
</p>
|
||||
<span class="search-controlls">
|
||||
<input id="searchInput" type="search" placeholder="Start typing to filter..." />
|
||||
<p id="press-enter-msg" class="press-enter">Press enter for deep search</p>
|
||||
</span>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/search">Search</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/all">View All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/inventory">My Inventory</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/submit">Submit Listing</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api">API</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/browse">Categories</a>
|
||||
<ul>
|
||||
{categories.map((category) => (
|
||||
<li>
|
||||
<a href={`/${slugify(category.name)}`}>{category.name}</a>
|
||||
<ul>
|
||||
{category.sections.map((section) => (
|
||||
<li>
|
||||
<a href={`/${slugify(category.name)}/${slugify(section.name)}`}>{section.name}</a>
|
||||
<ul>
|
||||
{(section.services || []).map((service) => (
|
||||
<li title={service.description || ''}>
|
||||
<a href={`/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`}>
|
||||
{service.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about">About</a>
|
||||
<ul>
|
||||
<li><a href="/about#objective">Objective</a></li>
|
||||
<li><a href="/about#criteria">Listing Criteria</a></li>
|
||||
<li><a href="/about#contributing">Contributing</a></li>
|
||||
<li>
|
||||
<a href="/about#acknowledgements">Acknowledgements</a>
|
||||
<ul>
|
||||
<li><a href="/about#contributors">Contributors</a></li>
|
||||
<li><a href="/about#sponsors">Sponsors</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/about#author">Author</a></li>
|
||||
<li><a href="/about#license">License</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/sitemap">Sitemap</a>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<main>
|
||||
<h2>Sitemap</h2>
|
||||
<p>
|
||||
Below is a full listing of all pages on this site.<br />
|
||||
<small
|
||||
>As reflected in our <a href="/sitemap-index.xml">sitemap.xml</a></small
|
||||
>
|
||||
</p>
|
||||
<span class="search-controlls">
|
||||
<input
|
||||
id="searchInput"
|
||||
type="search"
|
||||
placeholder="Start typing to filter..."
|
||||
/>
|
||||
<p id="press-enter-msg" class="press-enter">
|
||||
Press enter for deep search
|
||||
</p>
|
||||
</span>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/search">Search</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/all">View All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/inventory">My Inventory</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/submit">Submit Listing</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api">API</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/browse">Categories</a>
|
||||
<ul>
|
||||
{
|
||||
categories.map((category) => (
|
||||
<li>
|
||||
<a href={`/${slugify(category.name)}`}>{category.name}</a>
|
||||
<ul>
|
||||
{category.sections.map((section) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/${slugify(category.name)}/${slugify(section.name)}`}
|
||||
>
|
||||
{section.name}
|
||||
</a>
|
||||
<ul>
|
||||
{(section.services || []).map((service) => (
|
||||
<li title={service.description || ''}>
|
||||
<a
|
||||
href={`/${slugify(category.name)}/${slugify(section.name)}/${slugify(service.name)}`}
|
||||
>
|
||||
{service.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about">About</a>
|
||||
<ul>
|
||||
<li><a href="/about#objective">Objective</a></li>
|
||||
<li><a href="/about#criteria">Listing Criteria</a></li>
|
||||
<li><a href="/about#contributing">Contributing</a></li>
|
||||
<li>
|
||||
<a href="/about#acknowledgements">Acknowledgements</a>
|
||||
<ul>
|
||||
<li><a href="/about#contributors">Contributors</a></li>
|
||||
<li><a href="/about#sponsors">Sponsors</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="/about#author">Author</a></li>
|
||||
<li><a href="/about#license">License</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/sitemap">Sitemap</a>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Select elements for events (search field, each result, and message str)
|
||||
const filterInput = document.querySelector<HTMLInputElement>('input');
|
||||
const pages = document.querySelectorAll<HTMLElement>('li');
|
||||
const pressEnterMsg = document.getElementById('press-enter-msg');
|
||||
if (!pressEnterMsg) throw new Error('pressEnterMsg element not found');
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Select elements for events (search field, each result, and message str)
|
||||
const filterInput = document.querySelector<HTMLInputElement>('input');
|
||||
const pages = document.querySelectorAll<HTMLElement>('li');
|
||||
const pressEnterMsg = document.getElementById('press-enter-msg');
|
||||
if (!pressEnterMsg) throw new Error('pressEnterMsg element not found');
|
||||
|
||||
// Instant search/filter functionality
|
||||
filterInput?.addEventListener('input', (e: Event) => {
|
||||
const filter = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
pressEnterMsg.style.visibility = filter.length > 0 ? 'visible' : 'hidden';
|
||||
Array.from(pages).reduce((count, page) => {
|
||||
const match = page.textContent?.toLowerCase().includes(filter) || false;
|
||||
page.style.display = match ? 'block' : 'none';
|
||||
return count + (match ? 1 : 0);
|
||||
}, 0);
|
||||
});
|
||||
// Handle keypress events, so Esc clears search, and Entr runs deep search
|
||||
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (!filterInput) return;
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
const searchTerm = encodeURIComponent(filterInput.value);
|
||||
window.location.href = `/search/${searchTerm}`;
|
||||
break;
|
||||
case 'Escape':
|
||||
filterInput.value = '';
|
||||
filterInput.blur();
|
||||
pressEnterMsg.style.visibility = 'hidden';
|
||||
pages.forEach(page => page.style.display = 'block');
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
// Instant search/filter functionality
|
||||
filterInput?.addEventListener('input', (e: Event) => {
|
||||
const filter = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
pressEnterMsg.style.visibility = filter.length > 0 ? 'visible' : 'hidden';
|
||||
Array.from(pages).reduce((count, page) => {
|
||||
const match = page.textContent?.toLowerCase().includes(filter) || false;
|
||||
page.style.display = match ? 'block' : 'none';
|
||||
return count + (match ? 1 : 0);
|
||||
}, 0);
|
||||
});
|
||||
// Handle keypress events, so Esc clears search, and Entr runs deep search
|
||||
document.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (!filterInput) return;
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
const searchTerm = encodeURIComponent(filterInput.value);
|
||||
window.location.href = `/search/${searchTerm}`;
|
||||
break;
|
||||
case 'Escape':
|
||||
filterInput.value = '';
|
||||
filterInput.blur();
|
||||
pressEnterMsg.style.visibility = 'hidden';
|
||||
pages.forEach((page) => (page.style.display = 'block'));
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
main {
|
||||
padding: 1rem;
|
||||
width: 1000px;
|
||||
max-width: calc(100% - 5rem);
|
||||
margin: 4rem auto 5rem auto;
|
||||
padding: 0 2rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
position: relative;
|
||||
p {
|
||||
font-size: 1.3rem;
|
||||
margin: 0;
|
||||
small {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.2rem;
|
||||
list-style: none;
|
||||
border-left: 3px solid #5f53f482;
|
||||
border-radius: 4px;
|
||||
li {
|
||||
font-weight: 600;
|
||||
ul li {
|
||||
font-weight: 500;
|
||||
ul li {
|
||||
font-weight: 400;
|
||||
ul li {
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
content: '━ ';
|
||||
color: #5f53f482;
|
||||
margin-left: -1.2rem;
|
||||
}
|
||||
a {
|
||||
transition: all ease-in-out 0.2s;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1rem;
|
||||
width: 1000px;
|
||||
max-width: calc(100% - 5rem);
|
||||
margin: 4rem auto 5rem auto;
|
||||
padding: 0 2rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
position: relative;
|
||||
p {
|
||||
font-size: 1.3rem;
|
||||
margin: 0;
|
||||
small {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover { opacity: 0.8; }
|
||||
}
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.2rem;
|
||||
list-style: none;
|
||||
border-left: 3px solid #5f53f482;
|
||||
border-radius: 4px;;
|
||||
li {
|
||||
font-weight: 600;
|
||||
ul li {
|
||||
font-weight: 500;
|
||||
ul li {
|
||||
font-weight: 400;
|
||||
ul li {
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
content: "━ ";
|
||||
color: #5f53f482;
|
||||
margin-left: -1.2rem;
|
||||
}
|
||||
a {
|
||||
transition: all ease-in-out 0.2s;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
gap: 1rem;
|
||||
font-size: 2rem;
|
||||
margin: -2rem 0 2rem -4rem;
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
width: fit-content;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@media (max-width: 768px) {
|
||||
margin: -2rem auto 1rem auto;
|
||||
}
|
||||
}
|
||||
|
||||
.search-controlls {
|
||||
float: right;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
position: absolute;
|
||||
@media (max-width: 768px) {
|
||||
position: relative;
|
||||
float: none;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
.press-enter {
|
||||
margin: 0.5rem 0 0 0;
|
||||
opacity: 0.5;
|
||||
visibility: hidden;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.25rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
font-size: 1rem;
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
color: var(--accent-3);
|
||||
background: var(--background-form);
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
gap: 1rem;
|
||||
font-size: 2rem;
|
||||
margin: -2rem 0 2rem -4rem;
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
width: fit-content;
|
||||
padding: 0.25rem 0.5rem;
|
||||
@media (max-width: 768px) {
|
||||
margin: -2rem auto 1rem auto;
|
||||
}
|
||||
}
|
||||
|
||||
.search-controlls {
|
||||
float: right;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
position: absolute;
|
||||
@media (max-width: 768px) {
|
||||
position: relative;
|
||||
float: none;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
.press-enter {
|
||||
margin: 0.5rem 0 0 0;
|
||||
opacity: 0.5;
|
||||
visibility: hidden;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input {
|
||||
padding: 0.25rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
border-radius: var(--curve-sm);
|
||||
font-size: 1rem;
|
||||
box-shadow: 3px 3px 0 var(--box-outline);
|
||||
color: var(--accent-3);
|
||||
background: var(--background-form);
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 4px 4px 0 var(--box-outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,142 +2,187 @@
|
|||
import Layout from '@layouts/Layout.astro';
|
||||
import AddNewService from '@components/things/AddNewService.svelte';
|
||||
|
||||
import { fetchGitHubStats } from '@utils/fetch-repo-info'
|
||||
import { fetchGitHubStats } from '@utils/fetch-repo-info';
|
||||
import { formatDate } from '@utils/dates-n-stuff';
|
||||
|
||||
const commits = (await fetchGitHubStats('lissy93/awesome-privacy') || {}).commits;
|
||||
|
||||
const commits = ((await fetchGitHubStats('lissy93/awesome-privacy')) || {})
|
||||
.commits;
|
||||
---
|
||||
|
||||
<Layout title="Awesome Privacy">
|
||||
<section>
|
||||
<h2>About our Data</h2>
|
||||
<p class="about-data">
|
||||
All data on Awesome Privacy is community maintained via Git,
|
||||
this keeps everything transparent, and means anyone can submit edits.
|
||||
You can learn more about how our data is managed on our <a href="/about#our-data">about page</a>.
|
||||
<br /><br />
|
||||
You can make ammendments/additions/removals simply by editing the
|
||||
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a> file.
|
||||
<br />
|
||||
Before you proceed, please first read our <a href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md">Contributing Docs</a>
|
||||
<br /><br />
|
||||
Awesome Privacy is a community-maintained resource, it's thanks to
|
||||
contributors like you, that it's able to grow and stay up to date 💜
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Submit an Addition</h2>
|
||||
<AddNewService client:load />
|
||||
</section>
|
||||
<section>
|
||||
<h2>About our Data</h2>
|
||||
<p class="about-data">
|
||||
All data on Awesome Privacy is community maintained via Git, this keeps
|
||||
everything transparent, and means anyone can submit edits. You can learn
|
||||
more about how our data is managed on our <a href="/about#our-data"
|
||||
>about page</a
|
||||
>.
|
||||
<br /><br />
|
||||
You can make ammendments/additions/removals simply by editing the
|
||||
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
|
||||
>awesome-privacy.yml</a
|
||||
> file.
|
||||
<br />
|
||||
Before you proceed, please first read our <a
|
||||
href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
|
||||
>Contributing Docs</a
|
||||
>
|
||||
<br /><br />
|
||||
Awesome Privacy is a community-maintained resource, it's thanks to contributors
|
||||
like you, that it's able to grow and stay up to date 💜
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Submit an Addition</h2>
|
||||
<AddNewService client:load />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Submit a Removal Request</h2>
|
||||
<p>
|
||||
You can submit a removal request by browsing to a given service's page,
|
||||
and clicking the "Request Removal" button.
|
||||
This will open a form where you can justify your reasoning, to get it
|
||||
deleted from the <a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a> file.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Submit a Removal Request</h2>
|
||||
<p>
|
||||
You can submit a removal request by browsing to a given service's page,
|
||||
and clicking the "Request Removal" button. This will open a form where you
|
||||
can justify your reasoning, to get it deleted from the <a
|
||||
href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
|
||||
>awesome-privacy.yml</a
|
||||
> file.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Edit a Listing</h2>
|
||||
<p>
|
||||
Edits are welcome! All data is located in
|
||||
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml">awesome-privacy.yml</a>.
|
||||
<br>
|
||||
To modify an entry, navigate to it's page, scroll to the bottom, and click "Edit".
|
||||
This will take you to directly to the relevant lines in the file, where you can make your changes.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Edit a Listing</h2>
|
||||
<p>
|
||||
Edits are welcome! All data is located in
|
||||
<a href="github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml"
|
||||
>awesome-privacy.yml</a
|
||||
>.
|
||||
<br />
|
||||
To modify an entry, navigate to it's page, scroll to the bottom, and click "Edit".
|
||||
This will take you to directly to the relevant lines in the file, where you
|
||||
can make your changes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Checklist</h2>
|
||||
<ul>
|
||||
<li>You must read the <a href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md">Contributing</a> guidelines before proceeding</li>
|
||||
<li>All listing must meed our <a href="/about#creteria">Criteria</a> to be considered privacy-respecting</li>
|
||||
<li>Double check that your changes haven't already been proposed</li>
|
||||
<li>If you're associated with a service included, you must declare your affiliation</li>
|
||||
<li>Before commiting changes, ensure the YAML syntax is valid and it complies with our schema</li>
|
||||
<li>Please complete the issue or PR description template in full, do not remove any fields</li>
|
||||
<li>All submissions must be made via <a href="https://github.com/Lissy93/awesome-privacy">our GitHub</a>, do not email/PM maintainers</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Checklist</h2>
|
||||
<ul>
|
||||
<li>
|
||||
You must read the <a
|
||||
href="https://github.com/Lissy93/awesome-privacy/blob/main/.github/CONTRIBUTING.md"
|
||||
>Contributing</a
|
||||
> guidelines before proceeding
|
||||
</li>
|
||||
<li>
|
||||
All listing must meed our <a href="/about#creteria">Criteria</a> to be considered
|
||||
privacy-respecting
|
||||
</li>
|
||||
<li>Double check that your changes haven't already been proposed</li>
|
||||
<li>
|
||||
If you're associated with a service included, you must declare your
|
||||
affiliation
|
||||
</li>
|
||||
<li>
|
||||
Before commiting changes, ensure the YAML syntax is valid and it
|
||||
complies with our schema
|
||||
</li>
|
||||
<li>
|
||||
Please complete the issue or PR description template in full, do not
|
||||
remove any fields
|
||||
</li>
|
||||
<li>
|
||||
All submissions must be made via <a
|
||||
href="https://github.com/Lissy93/awesome-privacy">our GitHub</a
|
||||
>, do not email/PM maintainers
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{commits && commits.length > 0 && (
|
||||
<section>
|
||||
<h2>Recent Changes</h2>
|
||||
<p class="about-data">
|
||||
You can view a full ledger of all updates made
|
||||
at <a href="https://github.com/lissy93/awesome-privacy">github.com/lissy93/awesome-privacy</a>
|
||||
</p>
|
||||
<ul class="commit-log">
|
||||
{commits.map((commit) => (
|
||||
<li title={commit.sha}>
|
||||
{commit.message}<br />
|
||||
<img width="14" src={commit.authorAvatar} />
|
||||
<small>
|
||||
By <a href={`https://github.com/${commit.authorUsername}`}>{commit.authorName || commit.authorUsername}</a>
|
||||
on <a href={`https://github.com/Lissy93/awesome-privacy/commit/${commit.sha}`}>{formatDate(commit.authorDate)}</a>
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{
|
||||
commits && commits.length > 0 && (
|
||||
<section>
|
||||
<h2>Recent Changes</h2>
|
||||
<p class="about-data">
|
||||
You can view a full ledger of all updates made at{' '}
|
||||
<a href="https://github.com/lissy93/awesome-privacy">
|
||||
github.com/lissy93/awesome-privacy
|
||||
</a>
|
||||
</p>
|
||||
<ul class="commit-log">
|
||||
{commits.map((commit) => (
|
||||
<li title={commit.sha}>
|
||||
{commit.message}
|
||||
<br />
|
||||
<img width="14" src={commit.authorAvatar} />
|
||||
<small>
|
||||
By{' '}
|
||||
<a href={`https://github.com/${commit.authorUsername}`}>
|
||||
{commit.authorName || commit.authorUsername}
|
||||
</a>
|
||||
on{' '}
|
||||
<a
|
||||
href={`https://github.com/Lissy93/awesome-privacy/commit/${commit.sha}`}
|
||||
>
|
||||
{formatDate(commit.authorDate)}
|
||||
</a>
|
||||
</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</Layout>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
width: 1000px;
|
||||
max-width: calc(100% - 5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
border-radius: var(--curve-sm);
|
||||
@media (max-width: 768px) {
|
||||
max-width: 95%;
|
||||
padding: 0.5rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
ul {
|
||||
margin-top: 0;
|
||||
padding-left: 1rem;
|
||||
list-style: circle;
|
||||
}
|
||||
.about-data {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
width: 1000px;
|
||||
max-width: calc(100% - 5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 0 2rem;
|
||||
border: 2px solid var(--box-outline);
|
||||
box-shadow: 6px 6px 0 var(--box-outline);
|
||||
background: var(--accent-fg);
|
||||
border-radius: var(--curve-sm);
|
||||
@media(max-width: 768px) {
|
||||
max-width: 95%;
|
||||
padding: 0.5rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
ul {
|
||||
margin-top: 0;
|
||||
padding-left: 1rem;
|
||||
list-style: circle;
|
||||
}
|
||||
.about-data {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.commit-log {
|
||||
column-width: 350px;
|
||||
li {
|
||||
small {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
a { text-transform: capitalize;}
|
||||
}
|
||||
img {
|
||||
border-radius: var(--curve-md);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.commit-log {
|
||||
column-width: 350px;
|
||||
li {
|
||||
small {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
a {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
img {
|
||||
border-radius: var(--curve-md);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -14,37 +14,59 @@ export const authorProjects = [
|
|||
{
|
||||
title: 'Web-Check',
|
||||
description: 'OSINT tool for analysing any website',
|
||||
icon: 'https://web-check.as93.net/web-check.png',
|
||||
icon: 'https://cdn.as93.net/logo/web-check/w256',
|
||||
link: 'https://github.com/lissy93/web-check',
|
||||
},
|
||||
{
|
||||
title: 'Dashy',
|
||||
description: 'Dashboard app, for organising your self-hosted services',
|
||||
icon: 'https://dashy.to/img/dashy.png',
|
||||
icon: 'https://cdn.as93.net/logo/dashy/w256',
|
||||
link: 'https://github.com/lissy93/dashy',
|
||||
},
|
||||
{
|
||||
title: 'Domain Locker',
|
||||
description:
|
||||
'All-in-one tool, for keeping track of your domain name portfolio',
|
||||
icon: 'https://cdn.as93.net/logo/domain-locker/w256',
|
||||
link: 'https://github.com/lissy93/domain-locker',
|
||||
},
|
||||
{
|
||||
title: 'Pixelflare',
|
||||
description: 'Ultra high-performance privacy-respecting image CDN',
|
||||
icon: 'https://cdn.as93.net/logo/pixelflare/w256',
|
||||
link: 'https://github.com/Lissy93/pixelflare',
|
||||
},
|
||||
{
|
||||
title: 'Networking Toolbox',
|
||||
description:
|
||||
'100+ offline-first networking lookups, calculators and conversions',
|
||||
icon: 'https://cdn.as93.net/logo/networking-toolbox/w256',
|
||||
link: 'https://github.com/Lissy93/networking-toolbox',
|
||||
},
|
||||
{
|
||||
title: 'Portainer-Templates',
|
||||
description: 'Compiled repository of 1-click Docker apps for self-hosting',
|
||||
icon: 'https://portainer-templates.as93.net/favicon.png',
|
||||
icon: 'https://cdn.as93.net/logo/portainer-templates/w256',
|
||||
link: 'https://github.com/lissy93/portainer-templates',
|
||||
},
|
||||
{
|
||||
title: 'AdGuardian',
|
||||
description: 'CLI tool for monitoring your networks traffic and AdGuard DNS stats',
|
||||
icon: 'https://adguardian.as93.net/favicon.png',
|
||||
description:
|
||||
'CLI tool for monitoring your networks traffic and AdGuard DNS stats',
|
||||
icon: 'https://cdn.as93.net/logo/adguardian/w256',
|
||||
link: 'https://github.com/lissy93/adguardian-term',
|
||||
},
|
||||
{
|
||||
title: 'Bug-Bounties',
|
||||
description: 'Database of websites which accept responsible vulnerability disclosure',
|
||||
icon: 'https://bug-bounties.as93.net/favicon.png',
|
||||
description:
|
||||
'Database of websites which accept responsible vulnerability disclosure',
|
||||
icon: 'https://cdn.as93.net/logo/bug-bounties',
|
||||
link: 'https://github.com/lissy93/bug-bounties',
|
||||
},
|
||||
{
|
||||
title: 'Git-In',
|
||||
description: 'Tools and resources to help beginners get into open source',
|
||||
icon: 'https://www.git-in.to/favicon.png',
|
||||
icon: 'https://cdn.as93.net/logo/git-in/w256',
|
||||
link: 'https://github.com/lissy93/git-in',
|
||||
},
|
||||
];
|
||||
|
|
@ -82,9 +104,8 @@ export const authorSocials = [
|
|||
},
|
||||
];
|
||||
|
||||
|
||||
export const aboutOurData = `
|
||||
All data is stored in
|
||||
All data is stored in
|
||||
[\`awesome-privacy.yml\`](https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml).
|
||||
|
||||
This file is then pulled into the website at build-time, and also used to generate
|
||||
|
|
@ -132,7 +153,7 @@ Use our public instance, at: \`https://api.awesome-privacy.xyz\` or [self-host y
|
|||
`;
|
||||
|
||||
export const projectRequirements = `
|
||||
For software to be included in this list, it must meet the following requirements:
|
||||
For software to be included in this list, it must meet the following requirements:
|
||||
|
||||
- **Privacy Respecting**
|
||||
- The project must respect users privacy, not collect more data than necessary, and store info securely
|
||||
|
|
@ -148,7 +169,7 @@ For software to be included in this list, it must meet the following requirement
|
|||
- Ideally it should be possible for the user to build and run/deploy the software themselves from source
|
||||
- **Actively Maintained**
|
||||
- The developers should address dependency updates and security patches in a timely manner
|
||||
- Ideally the source should have been updated within the last 12 months
|
||||
- Ideally the source should have been updated within the last 12 months
|
||||
- **Transparent**
|
||||
- It should be clear who is behind the project, what their motives are, and what (if any) the funding model is
|
||||
- For hosted solutions, the privacy policy should clearly state what data is collected, how it's used and how long it's stored
|
||||
|
|
@ -167,18 +188,20 @@ by the community, and the drawbacks / anti-features must be clearly listed along
|
|||
Usually these entries go within the "Notable Mentions" section instead._
|
||||
`;
|
||||
|
||||
export const appDescription = 'Privacy is a fundamental human right; '
|
||||
+ 'without it, we\'re just open books in a world where everyone\'s '
|
||||
+ 'watching. Let\'s take control back.\n'
|
||||
+ 'Migrating open-source applications which do not collect, sell or log your data is a great first step.'
|
||||
+ 'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
|
||||
|
||||
export const appDescription =
|
||||
'Privacy is a fundamental human right; ' +
|
||||
"without it, we're just open books in a world where everyone's " +
|
||||
"watching. Let's take control back.\n" +
|
||||
'Migrating open-source applications which do not collect, sell or log your data is a great first step.' +
|
||||
'Awesome Privacy is a directory of alternative privacy-respecting software and services.';
|
||||
|
||||
export default {
|
||||
title: 'Awesome Privacy | The Ultimate List of Private Apps',
|
||||
description: 'Your guide to finding privacy-respecting alternatives to popular software and services.',
|
||||
keywords: 'security, privacy, awesome privacy, data collection, free software, open source, privacy tools, privacy respecting software',
|
||||
author: 'Alicia Sykes',
|
||||
description:
|
||||
'Your guide to finding privacy-respecting alternatives to popular software and services.',
|
||||
keywords:
|
||||
'security, privacy, awesome privacy, data collection, free software, open source, privacy tools, privacy respecting software',
|
||||
author: 'Alicia Sykes',
|
||||
authorProjects,
|
||||
authorSocials,
|
||||
aboutOurData,
|
||||
|
|
|
|||
|
|
@ -3,91 +3,123 @@
|
|||
|
||||
/* Rubik Font Faces */
|
||||
@font-face {
|
||||
font-family: 'Rubik';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Rubik'), url('/fonts/Rubik/Rubik-Regular.ttf') format('truetype');
|
||||
font-family: 'Rubik';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local('Rubik'),
|
||||
url('/fonts/Rubik/Rubik-Regular.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rubik';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: local('Rubik Italic'), url('/fonts/Rubik/Rubik-Italic.ttf') format('truetype');
|
||||
font-family: 'Rubik';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local('Rubik Italic'),
|
||||
url('/fonts/Rubik/Rubik-Italic.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rubik';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local('Rubik Medium'), url('/fonts/Rubik/Rubik-Medium.ttf') format('truetype');
|
||||
font-family: 'Rubik';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src:
|
||||
local('Rubik Medium'),
|
||||
url('/fonts/Rubik/Rubik-Medium.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rubik';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
src: local('Rubik Medium Italic'), url('/fonts/Rubik/Rubik-MediumItalic.ttf') format('truetype');
|
||||
font-family: 'Rubik';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
src:
|
||||
local('Rubik Medium Italic'),
|
||||
url('/fonts/Rubik/Rubik-MediumItalic.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rubik';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: local('Rubik SemiBold'), url('/fonts/Rubik/Rubik-SemiBold.ttf') format('truetype');
|
||||
font-family: 'Rubik';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src:
|
||||
local('Rubik SemiBold'),
|
||||
url('/fonts/Rubik/Rubik-SemiBold.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rubik';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src: local('Rubik SemiBold Italic'), url('/fonts/Rubik/Rubik-SemiBoldItalic.ttf') format('truetype');
|
||||
font-family: 'Rubik';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
src:
|
||||
local('Rubik SemiBold Italic'),
|
||||
url('/fonts/Rubik/Rubik-SemiBoldItalic.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Libre Franklin Font Faces */
|
||||
@font-face {
|
||||
font-family: 'Libre Franklin';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local('Libre Franklin Bold'), url('/fonts/Libre_Franklin/LibreFranklin-Bold.ttf') format('truetype');
|
||||
font-family: 'Libre Franklin';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src:
|
||||
local('Libre Franklin Bold'),
|
||||
url('/fonts/Libre_Franklin/LibreFranklin-Bold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Lekton Font Faces */
|
||||
@font-face {
|
||||
font-family: 'Lekton';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: local('Lekton Bold'), url('/fonts/Lekton/Lekton-Bold.ttf') format('truetype');
|
||||
font-family: 'Lekton';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src:
|
||||
local('Lekton Bold'),
|
||||
url('/fonts/Lekton/Lekton-Bold.ttf') format('truetype');
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
Menlo,
|
||||
Monaco,
|
||||
Lucida Console,
|
||||
Liberation Mono,
|
||||
DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono,
|
||||
Courier New,
|
||||
monospace;
|
||||
font-family:
|
||||
Menlo,
|
||||
Monaco,
|
||||
Lucida Console,
|
||||
Liberation Mono,
|
||||
DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono,
|
||||
Courier New,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.heading, h1 {
|
||||
font-family: "Libre Franklin", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
.heading,
|
||||
h1 {
|
||||
font-family: 'Libre Franklin', sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.subtitle, h2, h3, h4, h5, h6 {
|
||||
font-family: "Lekton", sans-serif;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
.subtitle,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Lekton', sans-serif;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
html, body, p, a, ul, ol, li, blockquote, pre, strong, i {
|
||||
font-family: "Rubik", sans-serif;
|
||||
html,
|
||||
body,
|
||||
p,
|
||||
a,
|
||||
ul,
|
||||
ol,
|
||||
li,
|
||||
blockquote,
|
||||
pre,
|
||||
strong,
|
||||
i {
|
||||
font-family: 'Rubik', sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,51 @@
|
|||
html {
|
||||
--accent: #f45397;
|
||||
--accent-fg: #1e1f21;
|
||||
|
||||
html {
|
||||
--accent: #f45397;
|
||||
--accent-fg: #1e1f21;
|
||||
--accent-2: #ffdf60;
|
||||
--accent-3: #5f53f4;
|
||||
--accent-4: #28dffd;
|
||||
|
||||
--accent-2: #ffdf60;
|
||||
--accent-3: #5f53f4;
|
||||
--accent-4: #28dffd;
|
||||
--foreground: #fff;
|
||||
|
||||
--foreground: #fff;
|
||||
--curve-sm: 4px;
|
||||
--curve-md: 6px;
|
||||
--curve-lg: 12px;
|
||||
|
||||
--curve-sm: 4px;
|
||||
--curve-md: 6px;
|
||||
--curve-lg: 12px;
|
||||
--danger: #ff0048;
|
||||
--success: #00ff64;
|
||||
|
||||
--danger: #ff0048;
|
||||
--success: #00ff64;
|
||||
--transparent-accent: #5f53f482;
|
||||
|
||||
--transparent-accent: #5f53f482;
|
||||
--background: #151517;
|
||||
--bg-gradient-comp-1: #151517;
|
||||
--bg-gradient-comp-2: #151517;
|
||||
--background-form: #19191c;
|
||||
|
||||
--background: #151517;
|
||||
--bg-gradient-comp-1: #151517;
|
||||
--bg-gradient-comp-2: #151517;
|
||||
--background-form: #19191c;
|
||||
--box-outline: #000;
|
||||
|
||||
--box-outline: #000;
|
||||
&[data-theme='light'] {
|
||||
--accent: #f45397;
|
||||
--accent-fg: #fff;
|
||||
|
||||
&[data-theme='light'] {
|
||||
--accent: #f45397;
|
||||
--accent-fg: #fff;
|
||||
|
||||
--accent-2: #ffdf60;
|
||||
--accent-3: #5f53f4;
|
||||
--accent-4: #28dffd;
|
||||
|
||||
--foreground: #13151a;
|
||||
|
||||
--curve-sm: 4px;
|
||||
--curve-md: 6px;
|
||||
--curve-lg: 12px;
|
||||
|
||||
--danger: #ff0048;
|
||||
--success: #00ff64;
|
||||
|
||||
--transparent-accent: #5f53f482;
|
||||
|
||||
--background: #feecff;
|
||||
--bg-gradient-comp-1: #feecff;
|
||||
--bg-gradient-comp-2: #e1e4fb;
|
||||
--background-form: #fff;
|
||||
}
|
||||
--accent-2: #ffdf60;
|
||||
--accent-3: #5f53f4;
|
||||
--accent-4: #28dffd;
|
||||
|
||||
--foreground: #13151a;
|
||||
|
||||
--curve-sm: 4px;
|
||||
--curve-md: 6px;
|
||||
--curve-lg: 12px;
|
||||
|
||||
--danger: #ff0048;
|
||||
--success: #00ff64;
|
||||
|
||||
--transparent-accent: #5f53f482;
|
||||
|
||||
--background: #feecff;
|
||||
--bg-gradient-comp-1: #feecff;
|
||||
--bg-gradient-comp-2: #e1e4fb;
|
||||
--background-form: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
export interface ShortService {
|
||||
name: string;
|
||||
description: string;
|
||||
|
|
@ -42,7 +40,6 @@ export interface Category {
|
|||
sections: Section[];
|
||||
}
|
||||
|
||||
|
||||
export interface AwesomePrivacy {
|
||||
categories: Array<{
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
|
||||
function cleanUrl(inputString: string) {
|
||||
return inputString.replace(/['";]+/g, '').trim();
|
||||
}
|
||||
|
||||
export const site = cleanUrl(
|
||||
import.meta.env.SITE_URL || 'https://awesome-privacy.xyz',
|
||||
);
|
||||
|
||||
export const site = cleanUrl(import.meta.env.SITE_URL || 'https://awesome-privacy.xyz');
|
||||
export const title =
|
||||
'Awesome Privacy | Compare privacy-respecting alternatives to popular software & services';
|
||||
|
||||
export const title = 'Awesome Privacy | Compare privacy-respecting alternatives to popular software & services';
|
||||
|
||||
export const description = 'Your guide to escaping big tech, protecting your privacy, and reclaiming your digital life.';
|
||||
export const description =
|
||||
'Your guide to escaping big tech, protecting your privacy, and reclaiming your digital life.';
|
||||
|
|
|
|||
|
|
@ -1,60 +1,82 @@
|
|||
import { slugify } from '@utils/fetch-data';
|
||||
|
||||
export const makeRemovalRequest = (categoryName: string, sectionName: string, serviceName: string, yaml?: string) => {
|
||||
export const makeRemovalRequest = (
|
||||
categoryName: string,
|
||||
sectionName: string,
|
||||
serviceName: string,
|
||||
yaml?: string,
|
||||
) => {
|
||||
const title = `[REMOVAL] ${serviceName}`;
|
||||
const under = `**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}`
|
||||
+ `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
|
||||
const removalData = `&title=${encodeURIComponent(title)}&removal-data=`
|
||||
+ `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
|
||||
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new'
|
||||
const baseOptions = '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+'
|
||||
+ 'Review&projects=&template=removal.yml'
|
||||
const under =
|
||||
`**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}` +
|
||||
`](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
|
||||
const removalData =
|
||||
`&title=${encodeURIComponent(title)}&removal-data=` +
|
||||
`${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
|
||||
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new';
|
||||
const baseOptions =
|
||||
'?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' +
|
||||
'Review&projects=&template=removal.yml';
|
||||
return `${issueCreate}${baseOptions}${removalData}`;
|
||||
};
|
||||
|
||||
export const makeEditRequest = (categoryName: string, sectionName: string, serviceName: string, yaml?: string) => {
|
||||
export const makeEditRequest = (
|
||||
categoryName: string,
|
||||
sectionName: string,
|
||||
serviceName: string,
|
||||
yaml?: string,
|
||||
) => {
|
||||
const title = `[AMENDMENT] ${serviceName}`;
|
||||
const under = `**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}`
|
||||
+ `](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
|
||||
const removalData = `&title=${encodeURIComponent(title)}&amendment-data=`
|
||||
+ `${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
|
||||
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new'
|
||||
const baseOptions = '?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+'
|
||||
+ 'Review&projects=&template=amendment.yml'
|
||||
const under =
|
||||
`**${serviceName}** (source: [${categoryName} ➜ ${sectionName} ➜ ${serviceName}` +
|
||||
`](https://github.com/Lissy93/awesome-privacy/tree/main#${slugify(sectionName)}))`;
|
||||
const removalData =
|
||||
`&title=${encodeURIComponent(title)}&amendment-data=` +
|
||||
`${encodeURIComponent(yaml || '')}&service-title=${encodeURIComponent(under)}`;
|
||||
const issueCreate = 'https://github.com/Lissy93/awesome-privacy/issues/new';
|
||||
const baseOptions =
|
||||
'?assignees=lissy93&labels=Suggested+Removal%2CAwaiting+' +
|
||||
'Review&projects=&template=amendment.yml';
|
||||
return `${issueCreate}${baseOptions}${removalData}`;
|
||||
};
|
||||
|
||||
export const makeAdditionRequest = (formData: {
|
||||
listingCategory: string;
|
||||
serviceName: string;
|
||||
serviceUrl: string;
|
||||
serviceIcon: string;
|
||||
serviceDescription: string;
|
||||
serviceGithub: string;
|
||||
serviceTosdrId: string;
|
||||
serviceIosApp: string,
|
||||
serviceAndroidApp: string,
|
||||
serviceDiscordInvite: string,
|
||||
serviceSubreddit: string,
|
||||
serviceOpenSource: boolean;
|
||||
serviceSecurityAudited: boolean;
|
||||
serviceCrypto: boolean;
|
||||
additionalInfo: string;
|
||||
}, yamlText?: string) => {
|
||||
|
||||
const userInfo = formData.additionalInfo.split('\n').map(line => `> ${line}`).join('\n');
|
||||
const additionalInfoText: string = `\n${userInfo}`
|
||||
+ `\n\n**YAML Content for Addition**\n\n\`\`\`yaml\n${yamlText || '# nothing yet'}\n\`\`\`\n`
|
||||
+ `\n\n<sup>This ticket was submitted via `
|
||||
+ `<a href="https://awesome-privacy.xyz/submit">awesome-privacy.xyz/submit</a></sup>`;
|
||||
export const makeAdditionRequest = (
|
||||
formData: {
|
||||
listingCategory: string;
|
||||
serviceName: string;
|
||||
serviceUrl: string;
|
||||
serviceIcon: string;
|
||||
serviceDescription: string;
|
||||
serviceGithub: string;
|
||||
serviceTosdrId: string;
|
||||
serviceIosApp: string;
|
||||
serviceAndroidApp: string;
|
||||
serviceDiscordInvite: string;
|
||||
serviceSubreddit: string;
|
||||
serviceOpenSource: boolean;
|
||||
serviceSecurityAudited: boolean;
|
||||
serviceCrypto: boolean;
|
||||
additionalInfo: string;
|
||||
},
|
||||
yamlText?: string,
|
||||
) => {
|
||||
const userInfo = formData.additionalInfo
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
const additionalInfoText: string =
|
||||
`\n${userInfo}` +
|
||||
`\n\n**YAML Content for Addition**\n\n\`\`\`yaml\n${yamlText || '# nothing yet'}\n\`\`\`\n` +
|
||||
`\n\n<sup>This ticket was submitted via ` +
|
||||
`<a href="https://awesome-privacy.xyz/submit">awesome-privacy.xyz/submit</a></sup>`;
|
||||
|
||||
const issueTitle = `[ADDITION] ${formData.serviceName} (Complete)`;
|
||||
const queryParams = new URLSearchParams({
|
||||
'assignees': 'lissy93,liss-bot',
|
||||
'labels': '',
|
||||
'projects': '',
|
||||
'template': 'complete-addition.yml',
|
||||
'title': issueTitle,
|
||||
assignees: 'lissy93,liss-bot',
|
||||
labels: '',
|
||||
projects: '',
|
||||
template: 'complete-addition.yml',
|
||||
title: issueTitle,
|
||||
'listing-category': formData.listingCategory,
|
||||
'service-name': formData.serviceName,
|
||||
'service-url': formData.serviceUrl,
|
||||
|
|
@ -63,38 +85,59 @@ export const makeAdditionRequest = (formData: {
|
|||
'service-github': formData.serviceGithub,
|
||||
'service-tosdr-id': formData.serviceTosdrId,
|
||||
'service-opensource': formData.serviceOpenSource ? 'true' : 'false',
|
||||
'service-security-audited': formData.serviceSecurityAudited ? 'true' : 'false',
|
||||
'service-security-audited': formData.serviceSecurityAudited
|
||||
? 'true'
|
||||
: 'false',
|
||||
'service-crypto': formData.serviceCrypto ? 'true' : 'false',
|
||||
'additional-info': additionalInfoText,
|
||||
});
|
||||
const issueCreateUrl = 'https://github.com/Lissy93/awesome-privacy/issues/new';
|
||||
const issueCreateUrl =
|
||||
'https://github.com/Lissy93/awesome-privacy/issues/new';
|
||||
return `${issueCreateUrl}?${queryParams.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
export const makeSourceYamlLink = async (categoryName: string, sectionName: string, serviceName: string) => {
|
||||
export const makeSourceYamlLink = async (
|
||||
categoryName: string,
|
||||
sectionName: string,
|
||||
serviceName: string,
|
||||
) => {
|
||||
const sourceData = await fetchSrcData(categoryName, sectionName, serviceName);
|
||||
const lineNumbers = sourceData.lineNumbers || null;
|
||||
const numberRange = lineNumbers ? `L${lineNumbers.start}-L${lineNumbers.end}` : '';
|
||||
const yamlLink = 'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
const numberRange = lineNumbers
|
||||
? `L${lineNumbers.start}-L${lineNumbers.end}`
|
||||
: '';
|
||||
const yamlLink =
|
||||
'https://github.com/lissy93/awesome-privacy/blob/main/awesome-privacy.yml';
|
||||
return `${yamlLink}${numberRange}`;
|
||||
};
|
||||
|
||||
export const fetchSrcData = async (categoryName: string, sectionName: string, serviceName: string) => {
|
||||
const lineNumberData = await fetch('/api/line-numbers.json')
|
||||
.then((res) => res.json());
|
||||
export const fetchSrcData = async (
|
||||
categoryName: string,
|
||||
sectionName: string,
|
||||
serviceName: string,
|
||||
) => {
|
||||
const lineNumberData = await fetch('/api/line-numbers.json').then((res) =>
|
||||
res.json(),
|
||||
);
|
||||
|
||||
if ( lineNumberData
|
||||
&& lineNumberData[categoryName]
|
||||
&& lineNumberData[categoryName][sectionName]
|
||||
&& lineNumberData[categoryName][sectionName][serviceName]
|
||||
if (
|
||||
lineNumberData &&
|
||||
lineNumberData[categoryName] &&
|
||||
lineNumberData[categoryName][sectionName] &&
|
||||
lineNumberData[categoryName][sectionName][serviceName]
|
||||
) {
|
||||
return {
|
||||
lineNumbers: lineNumberData[categoryName][sectionName][serviceName].lineNumbers,
|
||||
lineNumbers:
|
||||
lineNumberData[categoryName][sectionName][serviceName].lineNumbers,
|
||||
yamlContent: lineNumberData[categoryName][sectionName][serviceName].yaml,
|
||||
};
|
||||
} else {
|
||||
console.error('No line number data found for', categoryName, sectionName, serviceName);
|
||||
console.error(
|
||||
'No line number data found for',
|
||||
categoryName,
|
||||
sectionName,
|
||||
serviceName,
|
||||
);
|
||||
return { lineNumbers: [], yamlContent: '' };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
32
web/src/utils/dates-n-stuff.test.ts
Normal file
32
web/src/utils/dates-n-stuff.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDate, timestampToDate } from './dates-n-stuff';
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats an ISO date string to en-GB short format', () => {
|
||||
const result = formatDate('2024-01-15');
|
||||
expect(result).toBe('15 Jan 24');
|
||||
});
|
||||
|
||||
it('formats a different date correctly', () => {
|
||||
const result = formatDate('2023-12-25');
|
||||
expect(result).toBe('25 Dec 23');
|
||||
});
|
||||
|
||||
it('handles full ISO datetime string', () => {
|
||||
const result = formatDate('2024-06-01T12:00:00Z');
|
||||
expect(result).toBe('01 Jun 24');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestampToDate', () => {
|
||||
it('converts a Unix timestamp (ms) to en-GB short format', () => {
|
||||
// 2024-01-15T00:00:00Z = 1705276800000
|
||||
const result = timestampToDate(1705276800000);
|
||||
expect(result).toBe('15 Jan 24');
|
||||
});
|
||||
|
||||
it('converts epoch 0 to 01 Jan 70', () => {
|
||||
const result = timestampToDate(0);
|
||||
expect(result).toBe('01 Jan 70');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,21 +2,22 @@ export const formatDate = (date: string): string => {
|
|||
return new Date(date).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: '2-digit'
|
||||
year: '2-digit',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const timestampToDate = (timestamp: number): string => {
|
||||
return new Date(timestamp).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: '2-digit'
|
||||
year: '2-digit',
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
export const timeAgo = (dateStr: string): string => {
|
||||
const seconds = Math.floor((new Date().getTime() - new Date(dateStr).getTime()) / 1000);
|
||||
const seconds = Math.floor(
|
||||
(new Date().getTime() - new Date(dateStr).getTime()) / 1000,
|
||||
);
|
||||
const intervals = {
|
||||
year: 31536000,
|
||||
month: 2592000,
|
||||
|
|
|
|||
106
web/src/utils/do-searchy-searchy.test.ts
Normal file
106
web/src/utils/do-searchy-searchy.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { prepareSearchItems } from './do-searchy-searchy';
|
||||
import type { SearchItem } from './do-searchy-searchy';
|
||||
import type { Category } from '../types/Service';
|
||||
|
||||
const makeCategory = (overrides: Partial<Category> = {}): Category =>
|
||||
({
|
||||
name: 'Test Category',
|
||||
sections: [],
|
||||
...overrides,
|
||||
}) as Category;
|
||||
|
||||
describe('prepareSearchItems', () => {
|
||||
it('returns an empty array for no categories', () => {
|
||||
expect(prepareSearchItems([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates a category item', () => {
|
||||
const categories = [makeCategory({ name: 'Privacy Tools' })];
|
||||
const items = prepareSearchItems(categories);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
type: 'Category',
|
||||
category: 'Privacy Tools',
|
||||
itemCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates section items with category context', () => {
|
||||
const categories = [
|
||||
makeCategory({
|
||||
name: 'Comms',
|
||||
sections: [
|
||||
{ name: 'Messaging', intro: 'Secure messaging apps', services: [] },
|
||||
],
|
||||
}),
|
||||
] as Category[];
|
||||
const items = prepareSearchItems(categories);
|
||||
const section = items.find((i: SearchItem) => i.type === 'Section');
|
||||
expect(section).toMatchObject({
|
||||
type: 'Section',
|
||||
sectionName: 'Messaging',
|
||||
description: 'Secure messaging apps',
|
||||
category: 'Comms',
|
||||
itemCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates service items with section and category context', () => {
|
||||
const categories = [
|
||||
makeCategory({
|
||||
name: 'Comms',
|
||||
sections: [
|
||||
{
|
||||
name: 'Messaging',
|
||||
services: [
|
||||
{
|
||||
name: 'Signal',
|
||||
description: 'Encrypted messenger',
|
||||
url: 'https://signal.org',
|
||||
github: 'signalapp/Signal-Android',
|
||||
icon: 'signal.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
] as Category[];
|
||||
const items = prepareSearchItems(categories);
|
||||
const service = items.find((i: SearchItem) => i.type === 'Service');
|
||||
expect(service).toMatchObject({
|
||||
type: 'Service',
|
||||
name: 'Signal',
|
||||
description: 'Encrypted messenger',
|
||||
url: 'https://signal.org',
|
||||
github: 'signalapp/Signal-Android',
|
||||
category: 'Comms',
|
||||
sectionName: 'Messaging',
|
||||
logo: 'signal.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('counts services across sections for category itemCount', () => {
|
||||
const categories = [
|
||||
makeCategory({
|
||||
name: 'Tools',
|
||||
sections: [
|
||||
{
|
||||
name: 'A',
|
||||
services: [
|
||||
{ name: 's1', description: '', url: '' },
|
||||
{ name: 's2', description: '', url: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'B',
|
||||
services: [{ name: 's3', description: '', url: '' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
] as Category[];
|
||||
const items = prepareSearchItems(categories);
|
||||
const cat = items.find((i: SearchItem) => i.type === 'Category');
|
||||
expect(cat?.itemCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +1,31 @@
|
|||
import type { Category } from '../types/Service';
|
||||
|
||||
export const prepareSearchItems = (categories: Category[]) => {
|
||||
const items: any = [];
|
||||
export interface SearchItem {
|
||||
type: 'Category' | 'Section' | 'Service';
|
||||
category: string;
|
||||
itemCount?: number;
|
||||
sectionName?: string;
|
||||
description?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
github?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export const prepareSearchItems = (categories: Category[]): SearchItem[] => {
|
||||
const items: SearchItem[] = [];
|
||||
// Add each category
|
||||
categories.forEach(category => {
|
||||
categories.forEach((category) => {
|
||||
items.push({
|
||||
type: 'Category',
|
||||
category: category.name,
|
||||
itemCount: (category.sections || []).reduce((acc, section) => {
|
||||
return acc + (section.services || []).length;
|
||||
}, 0),
|
||||
return acc + (section.services || []).length;
|
||||
}, 0),
|
||||
});
|
||||
|
||||
// Add section with category context
|
||||
category.sections.forEach(section => {
|
||||
category.sections.forEach((section) => {
|
||||
items.push({
|
||||
type: 'Section',
|
||||
sectionName: section.name,
|
||||
|
|
@ -21,9 +33,9 @@ export const prepareSearchItems = (categories: Category[]) => {
|
|||
category: category.name,
|
||||
itemCount: (section.services || []).length,
|
||||
});
|
||||
|
||||
|
||||
// Add service with section and category context
|
||||
(section.services || []).forEach(service => {
|
||||
(section.services || []).forEach((service) => {
|
||||
items.push({
|
||||
type: 'Service',
|
||||
name: service.name,
|
||||
|
|
@ -53,6 +65,6 @@ export const searchOptions = {
|
|||
{ name: 'description', weight: 0.1 },
|
||||
{ name: 'intro', weight: 0.1 },
|
||||
{ name: 'furtherInfo', weight: 0.1 },
|
||||
{ name: 'wordOfWarning', weight: 0.1 },
|
||||
{ name: 'wordOfWarning', weight: 0.1 },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { error } from './logger';
|
||||
|
||||
const doubleCheckPackageName = (packageStr: string) => {
|
||||
return packageStr.includes('id=') ? packageStr.split('id=')[1] : packageStr;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchAndroidInfo = async (androidPackage: string): Promise<AndroidInfo | null> => {
|
||||
export const fetchAndroidInfo = async (
|
||||
androidPackage: string,
|
||||
): Promise<AndroidInfo | null> => {
|
||||
const endpoint = `https://android-app-privacy.as93.net/${doubleCheckPackageName(androidPackage)}`;
|
||||
try {
|
||||
return await fetch(endpoint).then((res) => res.json());
|
||||
} catch (error) {
|
||||
console.error('Error fetching android data:', error);
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) {
|
||||
error('Android', `HTTP ${res.status} for ${androidPackage} (${endpoint})`);
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
error('Android', `Network error for ${androidPackage}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -43,5 +51,3 @@ export interface AndroidInfo {
|
|||
trackers: Tracker[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
37
web/src/utils/fetch-data.test.ts
Normal file
37
web/src/utils/fetch-data.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { slugify } from './fetch-data';
|
||||
|
||||
describe('slugify', () => {
|
||||
it('lowercases and replaces spaces with hyphens', () => {
|
||||
expect(slugify('Hello World')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('replaces & with "and"', () => {
|
||||
expect(slugify('Privacy & Security')).toBe('privacy-and-security');
|
||||
});
|
||||
|
||||
it('replaces + with "and"', () => {
|
||||
expect(slugify('Tools + Tips')).toBe('tools-and-tips');
|
||||
});
|
||||
|
||||
it('removes question marks', () => {
|
||||
expect(slugify('What is Privacy?')).toBe('what-is-privacy');
|
||||
});
|
||||
|
||||
it('handles multiple spaces', () => {
|
||||
expect(slugify('a b c')).toBe('a--b---c');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(slugify('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for undefined-like input', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(slugify(undefined as any)).toBe('');
|
||||
});
|
||||
|
||||
it('handles combined special characters', () => {
|
||||
expect(slugify('Q&A + FAQ?')).toBe('qanda-and-faq');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue