Merge pull request #395 from Lissy93/feat/web-tests

Feat/web tests
This commit is contained in:
Alicia Sykes 2026-02-26 16:07:44 +00:00 committed by GitHub
commit 714a87f31a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 8789 additions and 6332 deletions

96
.github/workflows/web-checks.yml vendored Normal file
View 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"

View file

@ -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"[![Privacy Policy](https://shields.tosdr.org/en_{tosdrId}.svg)](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"[![Open Source](https://img.shields.io/badge/-Open_Source-3DA639"
f"?style=flat&logo=opensourceinitiative&logoColor=white)]({link}) "
)
if app.get('securityAudited') == True:
statsStr += (
"![Security Audited](https://img.shields.io/badge/-Security_Audited-3DA639"
"?style=flat&logo=data:image/svg+xml;base64,"
"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAxTDMgNXY2YzAgNS41NSAzLjg0IDEwLjc0IDkgMTIgNS4xNi0xLjI2IDktNi40NSA5LTEyVjVsLTktNHoiLz48L3N2Zz4="
"&logoColor=white) "
)
if app.get('acceptsCrypto') == True:
statsStr += (
"![Accepts Anonymous Payment](https://img.shields.io/badge/-Anon_Payment_Accepted"
"%EF%B8%8F-3DA639?style=flat&logo=bitcoincash&logoColor=white) "
)
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"[![{name} on Google Play](https://img.shields.io/badge/-{encoded}-3bd47f"
f"?style=flat&logo=android&logoColor=white)]"
f"(https://play.google.com/store/apps/details?id={androidApp}) "
)
def appStoreBadge(name, iosApp):
if not iosApp: return ""
encoded = shieldsEncode(name)
return (
f"[![{name} on App Store](https://img.shields.io/badge/-{encoded}-0D96F6"
f"?style=flat&logo=appstore&logoColor=white)]"
f"({iosApp}) "
)
def redditBadge(subreddit):
if not subreddit or not subreddit.strip(): return ""
sub = subreddit.strip()
return (
f"[![r/{sub} on Reddit](https://img.shields.io/badge/-{sub}-FF4500"
f"?style=flat&logo=reddit&logoColor=white)]"
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"[![{name} on Discord](https://img.shields.io/badge/-{encoded}-5865F2"
f"?style=flat&logo=discord&logoColor=white)]"
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:]

14
web/.editorconfig Normal file
View 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
View file

@ -19,3 +19,5 @@ dist/
# macOS crap
.DS_Store
.vscode/

1
web/.nvmrc Normal file
View file

@ -0,0 +1 @@
24.11.0

17
web/.prettierignore Normal file
View 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
View 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
}
}
]
}

View file

@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View file

@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View file

@ -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

View file

@ -25,13 +25,25 @@ const integrations = [svelte(), partytown(), sitemap()];
// 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
View 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,
];

View file

@ -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.7.0",
"@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"
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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;
}

View file

@ -2,13 +2,19 @@
import FontAwesome from "@components/form/FontAwesome.svelte";
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,8 +23,8 @@ 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,
@ -50,7 +56,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 +71,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 +80,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,

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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);

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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' },
});
};

View file

@ -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>

View file

@ -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' },
});
};

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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.';

View file

@ -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: '' };
}
};

View 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');
});
});

View file

@ -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,

View 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);
});
});

View file

@ -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 },
],
};

View file

@ -1,9 +1,10 @@
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());
@ -43,5 +44,3 @@ export interface AndroidInfo {
trackers: Tracker[];
permissions: string[];
}

View 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');
});
});

View file

@ -1,17 +1,21 @@
import yaml from 'js-yaml';
import type { AwesomePrivacy } from '../types/Service';
const awesomePrivacyData = 'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
const awesomePrivacyData =
'https://raw.githubusercontent.com/Lissy93/awesome-privacy/main/awesome-privacy.yml';
export const fetchData = async (): Promise<AwesomePrivacy> => {
return await fetch(awesomePrivacyData)
return (await fetch(awesomePrivacyData)
.then((res) => res.text())
.then((data) => yaml.load(data))
.catch((err) => console.error('ah crap', err)) as AwesomePrivacy;
}
.catch((err) => console.error('ah crap', err))) as AwesomePrivacy;
};
export const slugify = (title: string) => {
return (title || '').toLowerCase().replace(/\s/g, '-').replace(/\+|&/g, 'and').replaceAll('?', '');
};
return (title || '')
.toLowerCase()
.replace(/\s/g, '-')
.replace(/\+|&/g, 'and')
.replaceAll('?', '');
};

View file

@ -1,5 +1,6 @@
export const fetchDiscordInfo = async (discordInvite: string): Promise<DiscordInfo | null> => {
export const fetchDiscordInfo = async (
discordInvite: string,
): Promise<DiscordInfo | null> => {
const endpoint = `https://discord-invite-info.as93.net/${discordInvite}`;
try {
return await fetch(endpoint).then((res) => res.json());

View file

@ -1,6 +1,6 @@
export const fetchDockerData = async (serviceName: string): Promise<TemplateResponse | null> => {
export const fetchDockerData = async (
serviceName: string,
): Promise<TemplateResponse | null> => {
const endpoint = `https://docker-info.as93.workers.dev/${serviceName}`;
try {
return await fetch(endpoint).then((res) => res.json());

View file

@ -1,5 +1,6 @@
export const fetchIosInfo = async (iosUrl: string): Promise<IoSApiResponse | null> => {
export const fetchIosInfo = async (
iosUrl: string,
): Promise<IoSApiResponse | null> => {
const endpoint = `https://ios-app-info.as93.net?appStoreUrl=${iosUrl}`;
try {
return await fetch(endpoint).then((res) => res.json());

View file

@ -1,5 +1,6 @@
export const fetchTosdrPrivacy = async (serviceId: string): Promise<PrivacyPolicyResponse | null> => {
export const fetchTosdrPrivacy = async (
serviceId: string,
): Promise<PrivacyPolicyResponse | null> => {
const endpoint = `https://privacy-policies.as93.workers.dev/${serviceId}`;
try {
return await fetch(endpoint).then((res) => res.json());

View file

@ -1,5 +1,6 @@
export const fetchRedditInfo = async (subreddit: string): Promise<RedditData | null> => {
export const fetchRedditInfo = async (
subreddit: string,
): Promise<RedditData | null> => {
const endpoint = `https://subreddit-info.as93.net/${subreddit}`;
try {
return await fetch(endpoint).then((res) => res.json());

View file

@ -1,7 +1,6 @@
export const fetchGitHubStats = async (github: string): Promise<GitHubStatsResponse | null> => {
export const fetchGitHubStats = async (
github: string,
): Promise<GitHubStatsResponse | null> => {
const endpoint = `https://repo-info.as93.workers.dev/${github}`;
try {
return await fetch(endpoint).then((res) => res.json());

View file

@ -1,5 +1,6 @@
export const fetchWebsiteInfo = async (url: string): Promise<WebsiteData | null> => {
export const fetchWebsiteInfo = async (
url: string,
): Promise<WebsiteData | null> => {
const endpoint = `https://site-info-fetch.as93.workers.dev/?url=${url}`;
try {
return await fetch(endpoint).then((res) => res.json());
@ -19,10 +20,10 @@ interface DNSRecord {
interface DNSRecords {
ns: {
records: DNSRecord[];
records: DNSRecord[];
};
mx: {
records: DNSRecord[];
records: DNSRecord[];
};
}
@ -60,7 +61,7 @@ interface Redirection {
found: boolean;
external: boolean;
url: string;
redirects: any[];
redirects: string[];
}
interface ResponseHeaders {

View file

@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { formatLink } from './parse-markdown';
describe('formatLink', () => {
it('strips https://', () => {
expect(formatLink('https://example.com')).toBe('example.com');
});
it('strips http://', () => {
expect(formatLink('http://example.com')).toBe('example.com');
});
it('strips www.', () => {
expect(formatLink('https://www.example.com')).toBe('example.com');
});
it('strips trailing slash', () => {
expect(formatLink('https://example.com/')).toBe('example.com');
});
it('strips multiple trailing slashes', () => {
expect(formatLink('https://example.com///')).toBe('example.com');
});
it('preserves path segments', () => {
expect(formatLink('https://example.com/path/to/page')).toBe(
'example.com/path/to/page',
);
});
it('handles bare domain', () => {
expect(formatLink('example.com')).toBe('example.com');
});
it('handles empty string', () => {
expect(formatLink('')).toBe('');
});
it('handles undefined-like input', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(formatLink(undefined as any)).toBe('');
});
});

View file

@ -23,19 +23,24 @@ export const parseMarkdown = (text: string | undefined): string => {
// Sanitize the input to remove <script> tags
const sanitizeHtml = (html: string): string => {
return html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
return html.replace(
/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
'',
);
};
// Configure marked with the custom renderer
marked.use({ renderer });
// Parse the markdown, then sanitize the HTML to remove <script> tags
const rawHtml = marked.parse(text, { async: false}) as string;
const rawHtml = marked.parse(text, { async: false }) as string;
const sanitizedHtml = sanitizeHtml(rawHtml);
return sanitizedHtml;
};
export const formatLink = (link: string) => {
return (link || '').replace(/^(https?:\/\/)?(www\.)?/, '').replace(/\/+$/, '');
return (link || '')
.replace(/^(https?:\/\/)?(www\.)?/, '')
.replace(/\/+$/, '');
};

View file

@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { analyzeSecurityChecks } from './security-check-mappings';
describe('analyzeSecurityChecks', () => {
it('classifies a passing HTTPS check as passed', () => {
const { passedChecks, failedChecks } = analyzeSecurityChecks({
is_valid_https: true,
});
expect(passedChecks).toContain('Valid HTTPS Connection');
expect(failedChecks).toHaveLength(0);
});
it('classifies is_valid_https: "no" as failed', () => {
// The pass logic uses (value === shouldPass) || (shouldPass === true && value !== "no")
// For boolean false, the second condition still passes (false !== "no" is true).
// Only the string "no" actually triggers a failure when shouldPass is true.
const { failedChecks } = analyzeSecurityChecks({
is_valid_https: 'no',
});
expect(failedChecks).toContain('Valid HTTPS Connection');
});
it('classifies a false-means-pass check correctly', () => {
// is_host_an_ipv4 should be false to pass
const { passedChecks } = analyzeSecurityChecks({
is_host_an_ipv4: false,
});
expect(passedChecks).toContain('Host is an IPv4 Address');
});
it('classifies a false-means-pass check as failed when true', () => {
const { failedChecks } = analyzeSecurityChecks({
is_host_an_ipv4: true,
});
expect(failedChecks).toContain('Host is an IPv4 Address');
});
it('handles multiple checks at once', () => {
const { passedChecks, failedChecks } = analyzeSecurityChecks({
is_valid_https: true,
is_host_an_ipv4: false,
is_suspended_page: true, // should fail (expected false)
});
expect(passedChecks).toHaveLength(2);
expect(failedChecks).toHaveLength(1);
expect(failedChecks).toContain('Suspended Page');
});
it('handles string "no" values for domain_recent checks', () => {
const { passedChecks } = analyzeSecurityChecks({
is_domain_recent: 'no',
});
expect(passedChecks).toContain('Domain Recently Created');
});
it('fails domain_recent when value is not "no"', () => {
const { failedChecks } = analyzeSecurityChecks({
is_domain_recent: 'yes',
});
expect(failedChecks).toContain('Domain Recently Created');
});
it('returns empty arrays for empty input', () => {
const { passedChecks, failedChecks } = analyzeSecurityChecks({});
expect(passedChecks).toHaveLength(0);
expect(failedChecks).toHaveLength(0);
});
});

View file

@ -1,5 +1,4 @@
const securityCheckMapping: {[key: string]: string} = {
const securityCheckMapping: { [key: string]: string } = {
is_host_an_ipv4: 'Host is an IPv4 Address',
is_uncommon_host_length: 'Uncommon Host Length',
is_uncommon_dash_char_count: 'Uncommon Number of Dashes in Host',
@ -51,7 +50,8 @@ const securityCheckMapping: {[key: string]: string} = {
is_masked_linux_elf_file: 'Masked Linux ELF File Detected',
is_masked_windows_exe_file: 'Masked Windows Executable Detected',
is_ms_office_file: 'Microsoft Office File Detected',
is_windows_exe_file_on_free_dynamic_dns: 'Windows Executable on Free Dynamic DNS',
is_windows_exe_file_on_free_dynamic_dns:
'Windows Executable on Free Dynamic DNS',
is_windows_exe_file_on_free_hosting: 'Windows Executable on Free Hosting',
is_windows_exe_file_on_ipv4: 'Windows Executable on IPv4 Address',
is_windows_exe_file: 'Windows Executable Detected',
@ -65,91 +65,98 @@ const securityCheckMapping: {[key: string]: string} = {
is_domain_very_recent: 'Domain Very Recently Created',
is_credit_card_field: 'Credit Card Field Present',
is_password_field: 'Password Field Present',
is_valid_https: 'Valid HTTPS Connection'
is_valid_https: 'Valid HTTPS Connection',
};
const passFailLogic: {[key: string]: boolean | string} = {
// True means the check needs to be true to pass
is_valid_https: true,
is_host_an_ipv4: false,
is_uncommon_host_length: false,
is_uncommon_dash_char_count: false,
is_uncommon_dot_char_count: false,
is_uncommon_host_name: false,
is_suspicious_url_pattern: false,
is_suspicious_file_extension: false,
is_robots_noindex: false,
is_suspended_page: false,
is_most_abused_tld: false,
is_uncommon_clickable_url: false,
is_phishing_heuristic: false,
is_possible_emotet: false,
is_redirect_to_search_engine: false,
is_redirect_to_wikipedia: false,
is_http_status_error: false,
is_http_server_error: false,
is_http_client_error: false,
is_suspicious_content: false,
is_url_accessible: true,
is_empty_page_title: false,
is_empty_page_content: false,
is_domain_ipv4_assigned: true,
is_domain_ipv4_private: false,
is_domain_ipv4_loopback: false,
is_domain_ipv4_reserved: false,
is_domain_ipv4_valid: true,
is_domain_blacklisted: false,
is_suspicious_domain: false,
is_sinkholed_domain: false,
is_defaced_heuristic: false,
is_masked_file: false,
is_risky_geo_location: false,
is_china_country: false,
is_nigeria_country: false,
is_non_standard_port: false,
is_email_address_on_url_query: false,
is_directory_listing: false,
is_exe_on_directory_listing: false,
is_zip_on_directory_listing: false,
is_php_on_directory_listing: false,
is_doc_on_directory_listing: false,
is_pdf_on_directory_listing: false,
is_apk_on_directory_listing: false,
is_linux_elf_file: false,
is_linux_elf_file_on_free_dynamic_dns: false,
is_linux_elf_file_on_free_hosting: false,
is_linux_elf_file_on_ipv4: false,
is_masked_linux_elf_file: false,
is_masked_windows_exe_file: false,
is_ms_office_file: false,
is_windows_exe_file_on_free_dynamic_dns: false,
is_windows_exe_file_on_free_hosting: false,
is_windows_exe_file_on_ipv4: false,
is_windows_exe_file: false,
is_android_apk_file_on_free_dynamic_dns: false,
is_android_apk_file_on_free_hosting: false,
is_android_apk_file_on_ipv4: false,
is_android_apk_file: false,
is_external_redirect: false,
is_risky_category: false,
is_domain_recent: "no",
is_domain_very_recent: "no",
is_credit_card_field: false,
is_password_field: false,
const passFailLogic: { [key: string]: boolean | string } = {
// True means the check needs to be true to pass
is_valid_https: true,
is_host_an_ipv4: false,
is_uncommon_host_length: false,
is_uncommon_dash_char_count: false,
is_uncommon_dot_char_count: false,
is_uncommon_host_name: false,
is_suspicious_url_pattern: false,
is_suspicious_file_extension: false,
is_robots_noindex: false,
is_suspended_page: false,
is_most_abused_tld: false,
is_uncommon_clickable_url: false,
is_phishing_heuristic: false,
is_possible_emotet: false,
is_redirect_to_search_engine: false,
is_redirect_to_wikipedia: false,
is_http_status_error: false,
is_http_server_error: false,
is_http_client_error: false,
is_suspicious_content: false,
is_url_accessible: true,
is_empty_page_title: false,
is_empty_page_content: false,
is_domain_ipv4_assigned: true,
is_domain_ipv4_private: false,
is_domain_ipv4_loopback: false,
is_domain_ipv4_reserved: false,
is_domain_ipv4_valid: true,
is_domain_blacklisted: false,
is_suspicious_domain: false,
is_sinkholed_domain: false,
is_defaced_heuristic: false,
is_masked_file: false,
is_risky_geo_location: false,
is_china_country: false,
is_nigeria_country: false,
is_non_standard_port: false,
is_email_address_on_url_query: false,
is_directory_listing: false,
is_exe_on_directory_listing: false,
is_zip_on_directory_listing: false,
is_php_on_directory_listing: false,
is_doc_on_directory_listing: false,
is_pdf_on_directory_listing: false,
is_apk_on_directory_listing: false,
is_linux_elf_file: false,
is_linux_elf_file_on_free_dynamic_dns: false,
is_linux_elf_file_on_free_hosting: false,
is_linux_elf_file_on_ipv4: false,
is_masked_linux_elf_file: false,
is_masked_windows_exe_file: false,
is_ms_office_file: false,
is_windows_exe_file_on_free_dynamic_dns: false,
is_windows_exe_file_on_free_hosting: false,
is_windows_exe_file_on_ipv4: false,
is_windows_exe_file: false,
is_android_apk_file_on_free_dynamic_dns: false,
is_android_apk_file_on_free_hosting: false,
is_android_apk_file_on_ipv4: false,
is_android_apk_file: false,
is_external_redirect: false,
is_risky_category: false,
is_domain_recent: 'no',
is_domain_very_recent: 'no',
is_credit_card_field: false,
is_password_field: false,
};
export const analyzeSecurityChecks = (checks: { [key: string]: boolean | string }) => {
let passedChecks = [];
let failedChecks = [];
export const analyzeSecurityChecks = (checks: {
[key: string]: boolean | string;
}) => {
const passedChecks = [];
const failedChecks = [];
for (const [check, value] of Object.entries(checks)) {
let shouldPass = passFailLogic.hasOwnProperty(check) ? passFailLogic[check] : false;
let actualPass = (value === shouldPass) || (shouldPass === true && value !== "no");
const shouldPass = Object.prototype.hasOwnProperty.call(
passFailLogic,
check,
)
? passFailLogic[check]
: false;
const actualPass =
value === shouldPass || (shouldPass === true && value !== 'no');
if (actualPass) {
passedChecks.push(securityCheckMapping[check]);
} else {
failedChecks.push(securityCheckMapping[check]);
}
if (actualPass) {
passedChecks.push(securityCheckMapping[check]);
} else {
failedChecks.push(securityCheckMapping[check]);
}
}
return { passedChecks, failedChecks };
}
};

View file

@ -1,5 +1,5 @@
import { vitePreprocess } from '@astrojs/svelte';
export default {
preprocess: vitePreprocess(),
preprocess: vitePreprocess(),
};

View file

@ -1,11 +1,11 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@utils/*": ["src/utils/*"]
}
}
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@utils/*": ["src/utils/*"]
}
}
}

16
web/vitest.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
resolve: {
alias: {
'@components': resolve(__dirname, 'src/components'),
'@layouts': resolve(__dirname, 'src/layouts'),
'@utils': resolve(__dirname, 'src/utils'),
},
},
});

File diff suppressed because it is too large Load diff