Compare commits

...
Sign in to create a new pull request.

47 commits

Author SHA1 Message Date
f897f812c3 German lang files 2025-07-23 20:56:21 +02:00
374207c594
fix: oopsie i forgot to update the imports 2025-07-21 20:04:59 +01:00
c11069b187
refactor: move app.css back to main folder ( how did it even get there ?? ) 2025-07-21 20:03:23 +01:00
1d4b121ea5 feat: add git commit hash 2025-07-21 19:49:55 +01:00
daaa819e6c
refactor: update icon imports to @cf/icons 2025-07-15 16:51:55 +01:00
ea1a492dc0
fix: timing update on timeline fetcher 2025-07-15 16:42:21 +01:00
3b8ca902f1
refactor: use Link HTTP header for pagination 2025-07-15 16:34:23 +01:00
99def58c8b
feat: partial favourites 2025-07-15 15:37:03 +01:00
c51a0b1e5d
feat: local and federated timelines 2025-07-15 15:08:37 +01:00
7db5ec7fae
remove redundant parameter from Lang() 2025-07-15 00:02:12 +01:00
1b25e56d0a
fix pinned post context 2025-07-14 23:57:18 +01:00
2d7c346577
fix: add gap between folreqs 2025-07-14 23:06:14 +01:00
ari melody
8e9fb6598e Merge pull request 'ga_IE Localisation, other localisation touchups' (#2) from Catster/campfire:design-v2 into design-v2
Reviewed-on: https://codeberg.org/arimelody/campfire/pulls/2
2025-07-14 20:58:20 +02:00
faab37a53b
replace boost emoji with boost icon 2025-07-14 19:21:56 +01:00
7ed8ebf6e5
design: re-add bg to notifications for consistancy with timeline 2025-07-14 18:51:13 +01:00
876e221400
design: re-add bg to disabled buttons 2025-07-14 18:48:31 +01:00
e3586f4eec
feat: follow requests 2025-07-14 18:45:38 +01:00
563541d0e6
icons: add tick and cross 2025-07-14 18:17:33 +01:00
00277741a8
refactor: move page header to own component 2025-07-14 18:16:40 +01:00
6f446fd871
feat: follow requests in sidebar 2025-07-14 17:42:20 +01:00
0dd903a4eb chore: update readme to change npm -> bun 2025-07-14 16:40:09 +01:00
3d1f38bdce
fix compose modal not following with page scroll 2025-07-14 04:53:55 +01:00
fe9d216552
render emoji in profile bios 2025-07-14 04:43:37 +01:00
a5a066be3d
fix profile pinned posts duplication
we really need a better system for aborting requests via state
changes; for non-pinned posts and notifications, it's possible to screw
up state by navigating in a particular way, causing fetched feeds to loop for
seemingly no reason.
2025-07-14 04:22:23 +01:00
22d6c5b90a
temporary: disable follow button until supported 2025-07-14 04:12:20 +01:00
455679a525
show posts on profile page 2025-07-14 04:10:16 +01:00
f6901085f5 ga_IE Localisation, other localisation touchups 2025-07-14 02:51:04 +01:00
d8efaccb30
hook up profile lock/bot icons 2025-07-14 01:41:02 +01:00
77665702b7
Button component improvements
buttons with a `href` now render as <a> elements, otherwise <button>
2025-07-14 01:06:16 +01:00
d0163ee094 icons: add bot and lock 2025-07-14 00:27:49 +01:00
449a11ee55
initial profile implementation! 2025-07-14 00:19:42 +01:00
667b11f2f4
add i18n to profile page 2025-07-13 22:00:33 +01:00
7752585488
add i18n for console logs 2025-07-13 20:44:54 +01:00
b170a532f6
use fqn for local account instead of building manually 2025-07-13 19:04:50 +01:00
b74b19cc73
add missing i18n for post view 2025-07-13 18:49:49 +01:00
30f3aadeaa
update bg-900 light theme 2025-07-13 18:38:18 +01:00
f4709a232d
Merge branch 'design-v2' of forge:blisstown/campfire into design-v2 2025-07-13 18:36:34 +01:00
e326ac858e
add localisation support
currently only en_GB (TODO: dynamic language pack imports)
2025-07-13 18:35:26 +01:00
a820b40318 feat: design pass for profile page 2025-07-13 18:22:03 +01:00
a1c1b5f4d0 design: minor touchups + dark theme changes 2025-07-13 16:50:15 +01:00
970590497f
fix width (???), return button backgrounds 2025-07-13 15:41:56 +01:00
b295b6f03a chore: migrate to bun 2025-07-13 15:29:21 +01:00
f771866a09
friendship ended with github. now codeberg is my best friend 2025-07-10 22:41:33 +01:00
c402f329a7
rework of navigation UI 2025-07-10 22:32:54 +01:00
0a563e6121
rework post design; refine light theme 2025-07-10 18:11:49 +01:00
8d9c3cc4fe
Merge branch 'main' into dev 2025-07-10 02:13:32 +01:00
a1ec63b7ec
improve light theme contrast 2025-07-10 02:10:01 +01:00
48 changed files with 2059 additions and 2005 deletions

View file

@ -35,11 +35,13 @@ will likely be incompatible, and the web console will get very upset.
## try it out!
- `git clone` this repo
- `npm install` the dependencies
- `npm run dev` to spin up the dev environment
campfire uses [bun](https://bun.sh/) as a package manager and runtime.
if you wish to run this in production, you need only `npm run build` and
- `git clone` this repo
- `bun install` the dependencies
- `bun run dev` to spin up the dev environment
if you wish to run this in production, you need only `bun run build` and
place the static files somewhere accessible by a static webhost, such as
nginx or apache! **note:** your web server should attempt to reach
`/fallback.html` before erroring out.

246
bun.lock Normal file
View file

@ -0,0 +1,246 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "campfire-client",
"devDependencies": {
"@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-auto": "^3.2.2",
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"svelte": "^4.2.18",
"vite": "^5.3.1",
},
},
},
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@poppanator/sveltekit-svg": ["@poppanator/sveltekit-svg@4.2.1", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "svelte": ">=4.x", "svgo": ">=3.x", "vite": ">=4.x" } }, "sha512-w7jl4EVOOF+X+uv2BEUiMDJwds+GfbczwGpcS0+rsjIsKYmqmwMi4ts3bVZR9ZvdFHWy5rS84U+pSBClz6cbBg=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.45.0", "", { "os": "android", "cpu": "arm" }, "sha512-2o/FgACbji4tW1dzXOqAV15Eu7DdgbKsF2QKcxfG4xbh5iwU7yr5RRP5/U+0asQliSYv5M4o7BevlGIoSL0LXg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.45.0", "", { "os": "android", "cpu": "arm64" }, "sha512-PSZ0SvMOjEAxwZeTx32eI/j5xSYtDCRxGu5k9zvzoY77xUNssZM+WV6HYBLROpY5CkXsbQjvz40fBb7WPwDqtQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.45.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BA4yPIPssPB2aRAWzmqzQ3y2/KotkLyZukVB7j3psK/U3nVJdceo6qr9pLM2xN6iRP/wKfxEbOb1yrlZH6sYZg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.45.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Pr2o0lvTwsiG4HCr43Zy9xXrHspyMvsvEw4FwKYqhli4FuLE5FjcZzuQ4cfPe0iUFCvSQG6lACI0xj74FDZKRA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.45.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lYE8LkE5h4a/+6VnnLiL14zWMPnx6wNbDG23GcYFpRW1V9hYWHAw9lBZ6ZUIrOaoK7NliF1sdwYGiVmziUF4vA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.45.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PVQWZK9sbzpvqC9Q0GlehNNSVHR+4m7+wET+7FgSnKG3ci5nAMgGmr9mGBXzAuE5SvguCKJ6mHL6vq1JaJ/gvw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hLrmRl53prCcD+YXTfNvXd776HTxNh8wPAMllusQ+amcQmtgo3V5i/nkhPN6FakW+QVLoUUr2AsbtIRPFU3xIA=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.45.0", "", { "os": "linux", "cpu": "arm" }, "sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.45.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA=="],
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.45.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.45.0", "", { "os": "linux", "cpu": "none" }, "sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.45.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.45.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.45.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.45.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-+CXwwG66g0/FpWOnP/v1HnrGVSOygK/osUbu3wPRy8ECXjoYKjRAyfxYpDQOfghC5qPJYLPH0oN4MCOjwgdMug=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.45.0", "", { "os": "win32", "cpu": "x64" }, "sha512-SRf1cytG7wqcHVLrBc9VtPK4pU5wxiB/lNIkNmW2ApKXIg+RpqwHfsaEK+e7eH4A1BpI6BX/aBWXxZCIrJg3uA=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@3.3.1", "", { "dependencies": { "import-meta-resolve": "^4.1.0" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ=="],
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.8", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg=="],
"@sveltejs/kit": ["@sveltejs/kit@2.22.5", "", { "dependencies": { "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-l5i+LcDaoymD2mg5ziptnHmzzF79+c9twJiDoLWAPKq7afMEe4mvGesJ+LVtm33A92mLzd2KUHgtGSqTrvfkvg=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@3.1.2", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.10", "svelte-hmr": "^0.16.0", "vitefu": "^0.2.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@2.1.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="],
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
"csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"devalue": ["devalue@5.1.1", "", {}, "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
"periscopic": ["periscopic@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"rollup": ["rollup@4.45.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.45.0", "@rollup/rollup-android-arm64": "4.45.0", "@rollup/rollup-darwin-arm64": "4.45.0", "@rollup/rollup-darwin-x64": "4.45.0", "@rollup/rollup-freebsd-arm64": "4.45.0", "@rollup/rollup-freebsd-x64": "4.45.0", "@rollup/rollup-linux-arm-gnueabihf": "4.45.0", "@rollup/rollup-linux-arm-musleabihf": "4.45.0", "@rollup/rollup-linux-arm64-gnu": "4.45.0", "@rollup/rollup-linux-arm64-musl": "4.45.0", "@rollup/rollup-linux-loongarch64-gnu": "4.45.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.45.0", "@rollup/rollup-linux-riscv64-gnu": "4.45.0", "@rollup/rollup-linux-riscv64-musl": "4.45.0", "@rollup/rollup-linux-s390x-gnu": "4.45.0", "@rollup/rollup-linux-x64-gnu": "4.45.0", "@rollup/rollup-linux-x64-musl": "4.45.0", "@rollup/rollup-win32-arm64-msvc": "4.45.0", "@rollup/rollup-win32-ia32-msvc": "4.45.0", "@rollup/rollup-win32-x64-msvc": "4.45.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WLjEcJRIo7i3WDDgOIJqVI2d+lAC3EwvOGy+Xfq6hs+GQuAA4Di/H72xmXkOhrIWFg2PFYSKZYfH0f4vfKXN4A=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
"sirv": ["sirv@3.0.1", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"svelte": ["svelte@4.2.20", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" } }, "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q=="],
"svelte-hmr": ["svelte-hmr@0.16.0", "", { "peerDependencies": { "svelte": "^3.19.0 || ^4.0.0" } }, "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA=="],
"svgo": ["svgo@4.0.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.4.1" }, "bin": "./bin/svgo.js" }, "sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="],
"vitefu": ["vitefu@0.2.5", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["vite"] }, "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
"svgo/css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"svgo/css-tree/mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
}
}

1664
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,19 @@
@import url("../font/inter/inter.css");
@import url("./font/inter/inter.css");
:root {
--bg-1000: #fff6de;
--bg-900: #f9f1db;
--bg-800: #f1e8cf;
--bg-700: #d2c9b1;
--bg-600: #f0f6c2;
--accent: #8d9936;
--bg-1000: #fffcf7;
--bg-900: #faf4e4;
--bg-800: #f2e8d7;
--bg-700: #d9ccad;
--bg-600: #edf5ba;
--accent: #92a40a;
--text: #322e1f;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-1000: #141016;
--bg-900: #1B141E;
--bg-1000: #0b090c;
--bg-900: #120d14;
--bg-800: #2A202F;
--bg-700: #443749;
--bg-600: #513D60;
@ -31,10 +31,6 @@
}
}
@supports (font-variation-settings: normal) {
body { font-family: InterVariable, sans-serif; }
}
body {
margin: 0;
padding: 0;
@ -48,6 +44,12 @@ body {
box-sizing: border-box;
}
@supports (font-variation-settings: normal) {
body {
font-family: InterVariable, sans-serif;
}
}
a {
color: var(--accent);
text-decoration: none;
@ -71,7 +73,7 @@ header, #widgets {
}
main {
width: 732px;
width: 700px;
}
img.emoji {
@ -79,6 +81,10 @@ img.emoji {
margin: -.2em 0;
}
hr {
border-color: color-mix(in srgb, transparent, var(--accent) 50%);
}
.throb {
animation: .25s throb alternate infinite ease-in;
}

7
src/img/icons/bot.svg Normal file
View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path d="M22 14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V16a2 2 0 0 1 2-2h14Zm-7 9a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3.293-5.707a1 1 0 0 0-1.338-.068l-.076.068-2 2a1 1 0 1 0 1.414 1.414L11 19.414l1.293 1.293.076.068a1 1 0 0 0 1.406-1.406l-.068-.076-2-2Zm8 0a1 1 0 0 0-1.338-.068l-.076.068-2 2a1 1 0 1 0 1.414 1.414L19 19.414l1.293 1.293.076.068a1 1 0 0 0 1.406-1.406l-.068-.076-2-2Z"/>
<rect width="4" height="10" x="4" y="16" rx="2"/>
<rect width="4" height="10" x="22" y="16" rx="2"/>
<path d="M14 9h2v5h-2V9Z"/>
<circle cx="15" cy="9" r="2"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

3
src/img/icons/cross.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path d="M20.763 7.627a2 2 0 1 1 2.908 2.746l-5.236 5.545 5.547 5.236.141.149a2 2 0 0 1-2.887 2.76l-5.546-5.237-5.236 5.547a2 2 0 1 1-2.908-2.746l5.236-5.546-5.546-5.235-.142-.149a2 2 0 0 1 2.731-2.893l.157.133 5.545 5.236 5.236-5.546Z"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

4
src/img/icons/lock.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<rect width="18" height="13" x="7" y="15" rx="2"/>
<path d="M16 7c1.608 0 2.895.534 3.873 1.382.95.822 1.535 1.875 1.902 2.83.369.96.547 1.898.634 2.582a11.746 11.746 0 0 1 .088 1.095l.002.074v.036l-3 .002v-.036c-.002-.034-.003-.09-.008-.162a8.703 8.703 0 0 0-.057-.628 8.303 8.303 0 0 0-.46-1.887c-.257-.67-.608-1.242-1.066-1.639C17.48 10.28 16.892 10 16 10c-.892 0-1.48.278-1.909.65-.457.396-.809.968-1.066 1.638a8.304 8.304 0 0 0-.46 1.887 8.799 8.799 0 0 0-.065.79v.037L9.5 15v-.036l.001-.074c.002-.061.004-.146.01-.25.011-.209.034-.5.078-.845.088-.684.267-1.622.636-2.582.367-.955.952-2.008 1.901-2.83C13.104 7.534 14.392 7 16 7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 730 B

3
src/img/icons/tick.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path d="M24.546 8.627a2 2 0 0 1 2.908 2.746l-.03.032-.03.032-.03.032-.03.032-.06.064-.06.063-.12.126c-.036.04-.02.023-.058.062l-.03.032-.059.062-.029.03-.059.063-.058.062-.03.03-.028.032-.116.122-.028.03-.03.032c-.097.103.04-.044-.057.06l-.453.48-.223.236-.11.117-.11.116-.856.907-.052.055-.053.056-.31.328-.051.055-.026.027-.026.027c-.114.122.064-.067-.05.054l-.026.027-.025.027-.051.055-.026.026-.025.027-.102.108-.05.052-.026.028-.024.026-.1.107-.051.052-.15.159-.024.026-.026.026-.049.053-.1.105-.023.026-.025.026-.025.027-.025.025-.097.104-.099.104-.024.026-.025.026-.146.155-.048.052-.049.05-.097.103-.049.052-.047.05-.097.103-.096.102c-.09.095.042-.045-.049.05l-.023.026-.024.025-.192.203-.023.026-.024.024-.024.026-.024.025-.38.403-.095.1-.095.1c-.04.043-.006.008-.047.05l-.023.026-.025.025-.093.1-.19.2-.023.025-.024.025-.046.05-.19.2-.047.05-.047.05-.19.2-.023.025-.023.025c-.084.089.036-.038-.048.05l-.047.05-.048.05a59.55 59.55 0 0 1-.094.1c-.104.11.057-.059-.047.051l-.048.05-.047.05-.023.025-.025.025c-.058.062.011-.01-.047.051-.042.046-.005.005-.048.05l-.094.1-.025.026-.023.025-.048.051-.048.05-.335.355-1.572 1.666-.102.107-.025.027-.025.027-.026.027-.025.026-.102.109-.026.027-.05.055-.027.026-.051.055-.026.027-.026.027-.103.11c-.033.035-.02.02-.052.056l-.053.054-.025.028-.053.055-.026.029-.105.11c-.052.056 0 .002-.053.056l-.026.029-.026.027-.027.028-.027.029-.053.056-.106.113c-.066.07.012-.014-.054.056l-.026.029-.028.028-.107.114c-.076.08-.032.035-.108.116l-.11.115-.11.116-.027.03-.083.087-.222.236-.226.238-.113.12-.03.031-.084.091-.03.03-.028.03-.03.03-.027.032-.06.06c-.123.132.067-.069-.057.062l-.029.031-.03.03-.028.032-.058.062-.03.03-.029.032-.03.031-.059.063-.06.062c-.09.097-.026.03-.117.126l-.24.255a2.002 2.002 0 0 1-2.91 0l-5.782-6.125-.133-.156a2 2 0 0 1 3.041-2.59l4.328 4.584.164-.173c.057-.06 0 .002.056-.058l.055-.06.054-.057.029-.029.027-.03.108-.114.028-.029.027-.03.027-.027.027-.03.054-.056.027-.029.027-.029.027-.028.027-.029.053-.056.027-.029.026-.028.054-.057.053-.055c.095-.1-.042.044.053-.057l.21-.223.21-.22.103-.11.05-.055.027-.027.05-.054.027-.028.051-.054.103-.108.203-.216.2-.212.1-.106.026-.026.024-.027.026-.026.024-.027.025-.026.05-.053.025-.026.098-.105.05-.052.049-.052.099-.104.39-.414c.107-.113-.01.012.097-.102.098-.103-.05.052.048-.052l.049-.05.095-.103.097-.102.048-.05.023-.026.025-.025.191-.203.024-.026.024-.024.023-.026.025-.025.38-.403.094-.1.095-.1.047-.05.024-.026.024-.025.047-.05.048-.05.188-.2.023-.025.025-.025.047-.05.047-.05.047-.05.094-.1.048-.05.047-.05.188-.2.025-.025.023-.025.047-.05.048-.05.047-.05.095-.1c.067-.072-.02.02.046-.051.05-.052 0 .002.048-.05l.048-.05.024-.025.023-.025c.054-.058.04-.044.095-.101l.096-.1.023-.026.024-.025.047-.051.025-.025.023-.025.096-.101.023-.026.025-.025.191-.203 1.572-1.666.026-.027.025-.026.152-.161.103-.109.05-.054.027-.028.026-.026.206-.22.052-.055.052-.054.052-.056.264-.278.212-.226.053-.056.054-.057.055-.058.053-.056.109-.115.109-.116.055-.058.027-.029.027-.029.11-.117.223-.236.454-.48c.046-.05.01-.011.057-.06l.058-.062.115-.122.03-.031.029-.03.087-.094.03-.03.117-.126.06-.062.029-.031.03-.033.06-.062.06-.064.03-.032.029-.031.06-.064.06-.064Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

151
src/lang/de_DE.json Normal file
View file

@ -0,0 +1,151 @@
{
"compose_placeholders": [
"Was geht, %1?",
"Hau raus!",
"Leiste einen Beitrag!",
"Ich liebe es zu Posten!",
"Ein neuer Tag, ein neuer %1 post!"
],
"login": {
"welcome": "Willkommen, Fediverse-Nutzer!",
"enter_domain": "Bitte die Server-Domäne eingeben.",
"experimental": "Diese Software ist noch\n<strong><em>experimentele Software</em></strong>;\nFunktionen können jederzeit Probleme aufweisen!\n<br>\nWenn dies kein Problem ist, Willkommen am Bord!",
"button": "Einloggen",
"error": {
"no_domain": "Bitte eine valide Server-Domäne eingeben.",
"connection_failed": "Verbindung zum Server fehlgeschlagen! \nÜberprüfe die Browser-Konsole!",
"create_app": "Fehler beim erstellen einer App zum Server."
},
"made_with_tagline": "Gemacht mit Liebe ❤ von <a href=\"https://bliss.town\">bliss town</a>"
},
"navigation": {
"timeline": "Timeline",
"notifications": "Benachrichtigungen",
"follow_requests": "Follower-Anfragen",
"explore": "Entdecken",
"lists": "Listen",
"favourites": "Favoriten",
"bookmarks": "Lesezeichen",
"hashtags": "Hashtags",
"profile_information": "Profil Informationen",
"settings": "Einstellungen",
"log_out": "Ausloggen",
"back": "Zurück"
},
"follow_requests": {
"none": "Gerade keine Follower-Anfragen für dich!"
},
"timeline": {
"home": "Home-Feed",
"local": "Lokaler-Feed",
"federated": "Federations-Feed",
"fetching": "Feed wird geladen..."
},
"notification": {
"and_others": "und <strong>%1</strong> andere",
"mention": "%1 hat dich erwähnt.",
"reblog": "%1 hat deinen Post geboosted.",
"reaction": "%1 hat auf deinen Poste reagiert.",
"follow": "%1 hat die gefolgt.",
"follow_request": "%1 hat angefragt dir zu folgen.",
"favourite": "%1 hat deinen Post favorisiert.",
"poll": "%1's Umfrage wurde beendet.",
"update": "%1 hat deren Post bearbeitet.",
"default": "%1 hat dich angestupst!",
"fetching": "Benachrichtigungen werden geladen..."
},
"post": {
"loading": "Post wird geladen...",
"pinned": "📌 Angepinnter Post",
"by": "Post von %1",
"boosted": "%1 hat diesen Post geboosted.",
"actions": {
"reply": "Antworten",
"boost": "Boost",
"favourite": "Favorisieren",
"quote": "Zitieren",
"react": "Reagieren",
"more": "Mehr",
"delete": "Löschen"
},
"warning": {
"placeholder": "Inhaltswarnung",
"show": "(Klicken zum Anzeigen)",
"hide": "(Klicken zum Verbergen)"
},
"visibility": {
"public": "Öffentlich",
"unlisted": "Nicht gelistet",
"follow_only": "Nur Follower",
"private": "Privat",
"direct": "Direktnachricht"
}
},
"profile": {
"locked": "Dies ist ein privates Profil.",
"bot": "Dies ist ein automatiesiertes Profil.",
"followers": "Folgen",
"following": "Gefolgt",
"follow": "Folgen",
"home_instance": "Auf Hauptinstanz ansehen",
"more": "Mehr",
"posts": "Posts",
"replies": "Antworten",
"media": "Medien",
"loading": "Profil wird geladen..."
},
"logs": {
"logged_in": "Eingeloggt als %1",
"server_detected": "Server entdeckt als %1 (%2) mit Funktionen: {%3}}",
"server_unsupported": "Server %1 wird nicht unterstützt (%2). Funktionen können nicht garantiert werden und dein Haus wird vermutlich in Flammen aufgehen",
"no_hostname": "Versucht mit Server ohne Hostnamen zu verbinden",
"no_https": "Verbindung zu einem unsicheren Server feige verweigert.“",
"connection_failed": "Verbindung zu %1 fehlgeschlagen",
"post_fetch_failed": "Fehler beim Laden des Posts",
"post_fetch_failed_id": "Fehler beim Laden des Posts %1",
"post_parse_failed": "Fehler beim Analysieren des Posts",
"post_parse_failed_id": "Fehler beim Analysieren des Posts %1",
"profile_fetch_failed": "Fehler beim Laden des Profils",
"profile_fetch_failed_id": "Fehler beim Laden des Profils %1",
"token_revoke_failed": "Token widerrufen fehlgeschlagen! Daten werden trotzdem gedumpt",
"sound_does_not_exist": "Versucht Sound \"%1\" abzuspielen, aber es existiert nicht!",
"account_data_empty": "Profil Daten wurden analysiert, aber keine Daten wurden gefunden!",
"timeline_fetch_failed": "Fehler beim Anfragen der Timeline"
},
"error": {
"bad_request": "Fehlgeschlagener Request",
"invalid_auth_code": "Ungültiger Authcode",
"connection_failed": "Verbindung fehlgeschlagen <code>%1</code>.",
"post_fetch_failed": "Fehler beim Anzeigen des Posts <code>%1</code>."
},
"time": {
"in": "In %1",
"ago": "Vor %1",
"second": "s",
"minute": "m",
"hour": "h",
"day": "t",
"week": "w",
"year": "j"
},
"compose": "Posten",
"search": "Suchen",
"loading": "Nur ein Moment...",
"source": "Quellcode",
"issues": "Issues"
}

151
src/lang/en_GB.json Normal file
View file

@ -0,0 +1,151 @@
{
"compose_placeholders": [
"What's cooking, %1?",
"Speak your mind!",
"Federate something...",
"I sure love posting!",
"Another day, another %1 post!"
],
"login": {
"welcome": "Welcome, fediverse user!",
"enter_domain": "Please enter your server domain to log in.",
"experimental": "Please note this is\n<strong><em>extremely experimental software</em></strong>;\nthings are likely to break!\n<br>\nIf that's all cool with you, welcome aboard!",
"button": "Log in",
"error": {
"no_domain": "Please enter a server domain.",
"connection_failed": "Failed to connect to the server.\nCheck the browser console for details!",
"create_app": "Failed to create an application for this server."
},
"made_with_tagline": "made with ❤ by <a href=\"https://bliss.town\">bliss town</a>"
},
"navigation": {
"timeline": "Timeline",
"notifications": "Notifications",
"follow_requests": "Follow requests",
"explore": "Explore",
"lists": "Lists",
"favourites": "Favourites",
"bookmarks": "Bookmarks",
"hashtags": "Hashtags",
"profile_information": "Profile information",
"settings": "Settings",
"log_out": "Log out",
"back": "Back"
},
"follow_requests": {
"none": "no follow requests to action right now!"
},
"timeline": {
"home": "Home",
"local": "Local",
"federated": "Federated",
"fetching": "getting the feed..."
},
"notification": {
"and_others": "and <strong>%1</strong> others",
"mention": "%1 mentioned you.",
"reblog": "%1 boosted your post.",
"reaction": "%1 reacted to your post.",
"follow": "%1 followed you.",
"follow_request": "%1 requested to follow you.",
"favourite": "%1 favourited your post.",
"poll": "%1's poll has ended.",
"update": "%1 updated their post.",
"default": "%1 poked you!",
"fetching": "fetching notifications..."
},
"post": {
"loading": "loading post...",
"pinned": "📌 Pinned post",
"by": "Post by %1",
"boosted": "%1 boosted this post.",
"actions": {
"reply": "Reply",
"boost": "Boost",
"favourite": "Favourite",
"quote": "Quote",
"react": "React",
"more": "More",
"delete": "Delete"
},
"warning": {
"placeholder": "Content warning",
"show": "(click to reveal)",
"hide": "(click to hide)"
},
"visibility": {
"public": "public",
"unlisted": "unlisted",
"follow_only": "followers only",
"private": "private",
"direct": "direct"
}
},
"profile": {
"locked": "This is a private account.",
"bot": "This is an automated account.",
"followers": "Followers",
"following": "Following",
"follow": "Follow",
"home_instance": "View on home instance",
"more": "More",
"posts": "Posts",
"replies": "Replies",
"media": "Media",
"loading": "loading profile..."
},
"logs": {
"logged_in": "Logged in as %1",
"server_detected": "Server detected as %1 (%2) with capabilities: {%3}}",
"server_unsupported": "Server %1 is unsupported (%2). Things may break, or not work as expected",
"no_hostname": "Attempted to connect to a server without providing a hostname",
"no_https": "Cowardly refusing to connect to an insecure server",
"connection_failed": "Failed to connect to %1",
"post_fetch_failed": "Failed to fetch post",
"post_fetch_failed_id": "Failed to fetch post %1",
"post_parse_failed": "Failed to parse post",
"post_parse_failed_id": "Failed to parse post %1",
"profile_fetch_failed": "Failed to fetch profile",
"profile_fetch_failed_id": "Failed to fetch profile %1",
"token_revoke_failed": "Token revocation failed! Dumping data anyways",
"sound_does_not_exist": "Attempted to play sound \"%1\", which does not exist!",
"account_data_empty": "Attempted to parse account data but no data was provided",
"timeline_fetch_failed": "Failed to retrieve timeline."
},
"error": {
"bad_request": "Bad request",
"invalid_auth_code": "Invalid auth code provided",
"connection_failed": "Failed to connect to <code>%1</code>.",
"post_fetch_failed": "Failed to retrieve post <code>%1</code>."
},
"time": {
"in": "in %1",
"ago": "%1 ago",
"second": "s",
"minute": "m",
"hour": "h",
"day": "d",
"week": "w",
"year": "y"
},
"compose": "Post",
"search": "Search",
"loading": "just a moment...",
"source": "source",
"issues": "issues"
}

145
src/lang/ga_IE.json Normal file
View file

@ -0,0 +1,145 @@
{
"compose_placeholders": [
"Céard atá ar bun, %1?",
"Abair do thuairim!",
"Cónaidhmigh rud éigin...",
"Is breá liom postáil cinnte!",
"Lá eile, postáil %1 eile!"
],
"login": {
"welcome": "Fáilte, a úsáideoir fediverse!",
"enter_domain": "Cuir isteach fearann do fhreastalaí le logáil isteach.",
"experimental": "Tabhair faoi deara gur\n<strong><em>bogearraí thar a bheith turgnamhach</em> iad seo</strong>;\nis dócha go mbrisfidh rudaí!\n<br>\nMás ceart go leor leat é sin, fáilte romhat!",
"button": "Logáil isteach",
"error": {
"no_domain": "Cuir isteach fearann freastalaí.",
"connection_failed": "Theip ar cheangal leis an bhfreastalaí.\nSeiceáil consól an bhrabhsálaí le haghaidh sonraí!",
"create_app": "Theip ar fheidhmchlár a chruthú don fhreastalaí seo."
},
"made_with_tagline": "déanta le ❤ ag <a href=\"https://bliss.town\">bliss town</a>"
},
"navigation": {
"timeline": "Amlíne",
"notifications": "Fógraí",
"explore": "Féach Thart",
"lists": "Liostaí",
"favourites": "Ceanáin",
"bookmarks": "Leabharmharcanna",
"hashtags": "Haischlibeanna",
"profile_information": "Faisnéis Phróifíle",
"settings": "Socruithe",
"log_out": "Logáil amach",
"back": "Fill"
},
"timeline": {
"home": "Baile",
"local": "Áitiúil",
"federated": "Cónaidhmithe",
"fetching": "ag fáil an fhotha..."
},
"notification": {
"and_others": "agus <strong>%1</strong> eile",
"mention": "Luaigh %1 thú.",
"reblog": "Chuir %1 borradh faoi do phost",
"reaction": "D'fhrithghníomhaigh %1 do do phost",
"follow": "Lean %1 thú",
"follow_request": "D'iarr %1 leanúint leat.",
"favourite": "Chuir %1 do phost i bhfabhar.",
"poll": "Chríochnaigh pobalbhreith %1.",
"update": "Nuashonraigh %1 a bpost",
"default": "Priocadh %1 thú!",
"fetching": "ag fáil an fógraí..."
},
"post": {
"loading": "ag lódáil an postáil...",
"by": "Postáil le %1",
"boosted": "Chuir %1 borradh faoin bpost seo.",
"actions": {
"reply": "Freagair",
"boost": "Borradh",
"favourite": "Ceanán",
"quote": "Sliocht",
"react": "Frithghníomhaigh",
"more": "Tuilleadh",
"delete": "Scrios"
},
"warning": {
"placeholder": "Rabhadh ábhair",
"show": "(cliceáil chun nochtadh)",
"hide": "(cliceáil chun a cheilt)"
},
"visibility": {
"public": "poiblí",
"unlisted": "neamhliostaithe",
"follow_only": "leantóirí amháin",
"private": "príobháideach",
"direct": "díreach"
}
},
"profile": {
"locked": "Is cuntas príobháideach é seo.",
"bot": "Is cuntas uathoibrithe é seo.",
"followers": "Leantóirí",
"following": "Ag leanúint",
"follow": "Lean",
"home_instance": "Féach ar ásc baile",
"more": "Tuilleadh",
"posts": "Poist",
"replies": "Freagraí",
"media": "Meáin",
"loading": "ag lódáil an próifíl..."
},
"logs": {
"logged_in": "Logáilte isteach mar %1",
"server_detected": "Freastalaí braite mar %1 (%2) le cumais: {%3}}",
"server_unsupported": "Ní thacaítear le freastalaí %1 (%2). D'fhéadfadh rudaí briseadh, nó gan oibriú mar a bhíothas ag súil leis.",
"no_hostname": "Rinneadh iarracht ceangal le freastalaí gan ainm óstach a sholáthar",
"no_https": "Diúltaíodh ceangal leis an bhfreastalaí neamhshábháilte cosúil le cladhaire",
"connection_failed": "Theip ar cheangal le %1",
"post_fetch_failed": "Theip ar an bpost a fháil",
"post_fetch_failed_id": "Theip ar an bpost %1 a fháil",
"post_parse_failed": "Theip ar an bpost a pharsáil",
"post_parse_failed_id": "Theip ar an bpost %1 a pharsáil",
"profile_fetch_failed": "Theip ar phróifíl a fháil",
"profile_fetch_failed_id": "Theip ar phróifíl a fháil %1",
"token_revoke_failed": "Theip ar chúlghairm an chomhartha! Ag dumpáil sonraí ar aon nós",
"sound_does_not_exist": "Rinneadh iarracht fuaim \"%1\" a sheinm, ach níl sé ann!",
"account_data_empty": "Rinneadh iarracht sonraí cuntais a pharsáil ach níor soláthraíodh aon sonraí",
"timeline_fetch_failed": "Theip ar an amlíne a aisghabháil."
},
"error": {
"bad_request": "Droch-iarratas",
"invalid_auth_code": "Cód údaraithe neamhbhailí curtha ar fáil",
"connection_failed": "Theip ar cheangal le <code>%1</code>.",
"post_fetch_failed": "Theip ar aisghabháil an phoist <code>%1</code>."
},
"time": {
"in": "i gceann %1",
"ago": "%1 ó shin",
"second": "so",
"minute": "no",
"hour": "ua",
"day": "lá",
"week": "se",
"year": "bl"
},
"compose": "Cruthaigh",
"search": "Cuardaigh",
"loading": "fan nóiméad...",
"source": "foinse",
"issues": "saincheisteanna"
}

View file

@ -1,6 +1,9 @@
import { server } from '$lib/client/server.js';
import { parseEmoji, renderEmoji } from '$lib/emoji.js';
import { get, writable } from 'svelte/store';
import Lang from '$lib/lang';
const lang = Lang();
const cache = writable({});
@ -11,7 +14,7 @@ const cache = writable({});
*/
export function parseAccount(data) {
if (!data) {
console.error("Attempted to parse account data but no data was provided");
console.error(lang.string('logs.account_data_empty'));
return null;
}
let account = get(cache)[data.id];
@ -20,17 +23,26 @@ export function parseAccount(data) {
account = {};
account.id = data.id;
account.nickname = data.display_name.trim();
account.nickname = data.display_name.trim().replaceAll('<', '&lt;').replaceAll('>', '&gt;');
account.username = data.username;
account.name = account.nickname || account.username;
account.avatar_url = data.avatar;
account.banner_url = data.header;
account.url = data.url;
account.followers_count = data.followers_count;
account.following_count = data.following_count;
account.posts_count = data.statuses_count;
account.bio = data.note;
account.bot = data.bot;
account.locked = data.locked;
if (data.acct.includes('@'))
account.host = data.acct.split('@')[1];
else
account.host = get(server).host;
account.fqn = data.fqn || account.username + account.host;
account.mention = "@" + account.username;
if (account.host != get(server).host)
account.mention += "@" + account.host;
@ -41,6 +53,7 @@ export function parseAccount(data) {
});
account.rich_name = account.nickname ? renderEmoji(account.nickname, account.emojis) : account.username;
account.rich_bio = renderEmoji(account.bio, account.emojis);
cache.update(cache => {
cache[account.id] = account;

View file

@ -1,3 +1,32 @@
const errors = {
AUTHENTICATION_FAILED: "AUTHENTICATION_FAILED",
};
/**
* Parses a HTTP Link header
* @param {string} header - the HTTP Link header string
*/
function _parseLinkHeader(header) {
// remove whitespace and split
let links = header.replace(/\ /g, "").split(",");
return links.map(l => {
let parts = l.split(";");
// assuming 0th is URL, removing <>
let url = new URL(parts[0].slice(1, -1));
// get rel inbetween double-quotes
let rel = parts[1].match(/"(.*?)"/g)[0].slice(1, -1);
return {
url, rel
}
})
}
_parseLinkHeader(`<https://wetdry.world/api/v1/timelines/home?max_id=114857293229157171>; rel="next", <https://wetdry.world/api/v1/timelines/home?min_id=114857736990577458>; rel="prev"`)
/**
* GET /api/v1/instance
* @param {string} host - The domain of the target server.
@ -172,16 +201,108 @@ export async function getNotifications(host, token, min_id, max_id, limit, types
return data;
}
/**
* GET /api/v1/follow_requests
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} min_id - If provided, only shows follow requests since this ID.
* @param {string} max_id - If provided, only shows follow requests before this ID.
* @param {string} limit - The maximum number of follow requests to retrieve (default 40, max 80).
*/
export async function getFollowRequests(host, token, since_id, max_id, limit) {
let url = `https://${host}/api/v1/follow_requests`;
let params = new URLSearchParams();
if (since_id) params.append("since_id", since_id);
if (max_id) params.append("max_id", max_id);
if (limit) params.append("limit", limit);
const params_string = params.toString();
if (params_string) url += '?' + params_string;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/follow_requests/:account_id/authorize
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} account_id - The account ID of the follow request to accept
*/
export async function acceptFollowRequest(host, token, account_id) {
let url = `https://${host}/api/v1/follow_requests/${account_id}/authorize`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/follow_requests/:account_id/reject
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} account_id - The account ID of the follow request to reject
*/
export async function rejectFollowRequest(host, token, account_id) {
let url = `https://${host}/api/v1/follow_requests/${account_id}/reject`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* GET /api/v1/timelines/{timeline}
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} timeline - The name of the timeline to pull (default "home").
* @param {string} max_id - If provided, only shows posts after this ID.
* @param {boolean} local_only - If provided, only shows posts from the local instance
* @param {boolean} remote_only - If provided, only shows posts from other instances
*/
export async function getTimeline(host, token, timeline, max_id) {
export async function getTimeline(host, token, timeline, max_id, local_only, remote_only) {
let url = `https://${host}/api/v1/timelines/${timeline || "home"}`;
let params = new URLSearchParams();
if (max_id) params.append("max_id", max_id);
if (remote_only) params.append("remote", remote_only);
if (local_only) params.append("local", local_only);
const params_string = params.toString();
if (params_string) url += '?' + params_string;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
})
let links = _parseLinkHeader(data.headers.get("Link"));
return {
data: await data.json(),
prev: links.find(f=>f.rel=="prev"),
next: links.find(f=>f.rel=="next")
};
}
/**
* GET /api/v1/favourites
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} max_id - If provided, only shows posts after this ID.
*/
export async function getFavourites(host, token, max_id) {
let url = `https://${host}/api/v1/favourites`;
let params = new URLSearchParams();
if (max_id) params.append("max_id", max_id);
const params_string = params.toString();
@ -190,9 +311,15 @@ export async function getTimeline(host, token, timeline, max_id) {
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
}).then(res => res.json());
})
return data;
let links = _parseLinkHeader(data.headers.get("Link"));
return {
data: await data.json(),
prev: links.find(f=>f.rel=="prev"),
next: links.find(f=>f.rel=="next")
};
}
/**
@ -421,3 +548,74 @@ export async function getUser(host, token, user_id) {
return data;
}
/**
* GET /api/v1/accounts/lookup?acct={handle}
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} handle - The handle of the user to fetch.
*/
export async function lookupUser(host, token, handle) {
let url = `https://${host}/api/v1/accounts/lookup?acct=${handle}`;
const res = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
});
if (!res.ok) {
const json = await res.json();
if (json.error = errors.AUTHENTICATION_FAILED)
throw new Error("This method requires authentication");
}
const data = await res.json();
return data;
}
/**
* GET /api/v1/accounts/{user_id}/statuses
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} user_id - The ID of the user to fetch.
* @param {string} max_id - If provided, only shows notifications before this ID.
* @param {boolean} replies - If replies should be fetched.
* @param {boolean} boosts - If boosts should be fetched.
* @param {boolean} only_media - If only media should be fetched.
*/
export async function getUserPosts(host, token, user_id, max_id, show_replies, show_boosts, only_media) {
let url = new URL(`https://${host}/api/v1/accounts/${user_id}/statuses`);
let query = [];
if (!show_replies)
query.push('exclude_replies=true');
if (!show_boosts)
query.push('exclude_boosts=true');
if (only_media)
query.push('only_media=true');
if (max_id)
query.push(`max_id=${max_id}`);
url.search = query.join('&');
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
}).then(res => res.json());
return data;
}
/**
* GET /api/v1/accounts/{user_id}/statuses?pinned=true
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} user_id - The ID of the user to fetch.
*/
export async function getUserPinnedPosts(host, token, user_id) {
let url = `https://${host}/api/v1/accounts/${user_id}/statuses?pinned=true`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
}).then(res => res.json());
return data;
}

View file

@ -2,6 +2,9 @@ import * as api from '$lib/api.js';
import { writable } from 'svelte/store';
import { app_name } from '$lib/config.js';
import { browser } from "$app/environment";
import Lang from '$lib/lang';
const lang = Lang();
const server_types = {
UNSUPPORTED: "unsupported",
@ -35,11 +38,11 @@ server.subscribe(server => {
*/
export async function createServer(host) {
if (!host) {
console.error("Attempted to create server without providing a hostname");
console.error(lang.string('logs.no_hostname'));
return false;
}
if (host.startsWith("http://")) {
console.error("Cowardly refusing to connect to an insecure server");
console.error(lang.string('logs.no_https'));
return false;
}
@ -49,7 +52,7 @@ export async function createServer(host) {
if (host.startsWith("https://")) host = host.substring(8);
const data = await api.getInstance(host);
if (!data) {
console.error(`Failed to connect to ${host}`);
console.error(lang.string('logs.connection_failed', host));
return false;
}
@ -58,9 +61,9 @@ export async function createServer(host) {
server.capabilities = getCapabilities(server.type);
if (server.type === server_types.UNSUPPORTED) {
console.warn(`Server ${host} is unsupported (${server.version}). Things may break, or not work as expected`);
console.warn(lang.string('logs.server_unsupported', host, server.version));
} else {
console.log(`Server detected as "${server.type}" (${server.version}) with capabilities: {${server.capabilities.join(', ')}}`);
console.log(lang.string('logs.server_detected', server.type, server.version, server.capabilities.join(', ')));
}
return server;

28
src/lib/followRequests.js Normal file
View file

@ -0,0 +1,28 @@
import { server } from './client/server.js';
import { writable } from "svelte/store";
import * as api from "./api.js";
import { app } from './client/app.js';
import { get } from 'svelte/store';
import { parseAccount } from './account.js';
// Cache for all requests
export let followRequests = writable();
/**
* Gets all follow requests
* @param {boolean} force
*/
export async function fetchFollowRequests(force) {
// if already cached, return for now
if(!get(followRequests) && !force) return;
let newReqs = await api.getFollowRequests(
get(server).host,
get(app).token
);
// parse accounts
newReqs = newReqs.map((r) => parseAccount(r));
followRequests.set(newReqs);
}

64
src/lib/lang.js Normal file
View file

@ -0,0 +1,64 @@
import * as en_GB from '@cf/lang/en_GB.json';
// import * as ga_IE from '@cf/lang/ga_IE.json';
/**
* @returns Map<string, string | string[]>
*/
export default function init() {
let i18n = new Object();
// TODO: dynamic loading of language files
let language = en_GB;
let lang_code = 'en_GB';
i18n.lang = language;
i18n.lang_code = lang_code;
i18n.string = function(/* @type string */ key, ...args) {
const tokens = key.split('.');
let i = 0;
let token = tokens[i];
let res = this.lang;
while (true) {
res = res[token];
if (res === undefined) {
console.warn(`${key} not found for language ${this.lang_code}`);
return key;
}
if (typeof res === 'string' || res instanceof String)
break;
i++;
token = tokens[i];
}
i = 1;
while (true) {
if (args.length < i || !res.includes('%' + i))
break;
res = res.replaceAll('%' + i, args[i - 1]);
i++;
}
return res;
}
i18n.stringArray = function(/* @type string */ key) {
const tokens = key.split('.');
let i = 0;
let token = tokens[i];
let res = this.lang;
while (true) {
res = res[token];
if (res === undefined) {
console.warn(`${key} not found for language ${this.lang_code}`);
return key;
}
if (Array.isArray(res))
return res;
i++;
token = tokens[i];
}
}
return i18n;
}

View file

@ -1,3 +1,7 @@
import Lang from '$lib/lang';
const lang = Lang();
import sound_log from '../sound/log.ogg';
import sound_hello from '../sound/hello.ogg';
import sound_success from '../sound/success.ogg';
@ -16,7 +20,7 @@ export function playSound(name) {
if (!name) name = "default";
const sound = sounds[name];
if (!sound) {
console.warn(`Attempted to play sound "${name}", which does not exist!`);
console.warn(lang.string('lang.sound_does_not_exist', name));
return;
}
sound.pause();

View file

@ -1,10 +1,13 @@
import Lang from '$lib/lang';
const lang = Lang();
const denoms = [
{ unit: 's', min: 0 },
{ unit: 'm', min: 60 },
{ unit: 'h', min: 60 },
{ unit: 'd', min: 24 },
{ unit: 'w', min: 7 },
{ unit: 'y', min: 52 },
{ unit: lang.string('time.second'), min: 0 },
{ unit: lang.string('time.minute'), min: 60 },
{ unit: lang.string('time.hour'), min: 60 },
{ unit: lang.string('time.day'), min: 24 },
{ unit: lang.string('time.week'), min: 7 },
{ unit: lang.string('time.year'), min: 52 },
];
export function shorthand(date) {
@ -18,6 +21,6 @@ export function shorthand(date) {
unit = denoms[index].unit;
}
if (value > 0)
return Math.floor(value) + unit + " ago";
return "in " + Math.floor(value) + unit;
return lang.string('time.ago').replaceAll('%1', Math.floor(value) + unit);
return lang.string('time.in').replaceAll('%1', Math.floor(value) + unit);
}

View file

@ -3,44 +3,66 @@ import { server } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { get, writable } from 'svelte/store';
import { parsePost } from '$lib/post.js';
import Lang from '$lib/lang';
export const timeline = writable([]);
let loading = false;
const lang = Lang();
export async function getTimeline(clean) {
let loading = false;
let last_post = false;
export async function getTimeline(timelineType = "home", clean, localOnly = false, remoteOnly = false) {
if (loading) return; // no spamming!!
loading = true;
let last_post = false;
if (!clean && get(timeline).length > 0)
last_post = get(timeline)[get(timeline).length - 1].id;
if(clean) {
timeline.set([]);
last_post = false;
}
const timeline_data = await api.getTimeline(
get(server).host,
get(app).token,
"home",
last_post
);
let timeline_data;
switch(timelineType) {
case "favourites":
timeline_data = await api.getFavourites(
get(server).host,
get(app).token,
last_post
)
break;
default:
timeline_data = await api.getTimeline(
get(server).host,
get(app).token,
timelineType,
last_post,
localOnly,
remoteOnly
);
break;
}
if (!timeline_data) {
console.error(`Failed to retrieve timeline.`);
console.error(lang.string('logs.timeline_fetch_failed'));
loading = false;
return;
}
if (clean) timeline.set([]);
if (!clean) {
last_post = timeline_data.next.url.searchParams.get("max_id")
}
for (let i in timeline_data) {
const post_data = timeline_data[i];
for (let i in timeline_data.data) {
const post_data = timeline_data.data[i];
const post = await parsePost(post_data, 1);
if (!post) {
if (post === null || post === undefined) {
if (post_data.id) {
console.warn("Failed to parse post #" + post_data.id);
console.warn(lang.string('logs.post_parse_failed_id', post_data.id));
} else {
console.warn("Failed to parse post:");
console.warn(post_data);
console.warn(lang.string('logs.post_parse_failed'));
console.debug(post_data);
}
}
continue;

View file

@ -5,53 +5,64 @@
const dispatch = createEventDispatcher();
let className = "";
export { className as class };
export let active = false;
export let filled = false;
export let disabled = false;
export let centered = false;
export let label = undefined;
export let sound = "default";
export let href = false;
export let href = undefined;
export let onClick = undefined;
let classes = [];
function click() {
if (disabled) return;
if (href) {
location = href;
return;
}
playSound(sound);
dispatch('click');
}
afterUpdate(() => {
classes = [];
if (active) classes = ["active"];
if (filled) classes = ["filled"];
if (disabled) classes = ["disabled"];
classes = className.split(' ');
if (active) classes.push("active");
if (filled) classes.push("filled");
if (disabled) classes.push("disabled");
if (centered) classes.push("centered");
});
</script>
<button
type="button"
class={classes.join(' ')}
title={label}
aria-label={label}
on:click={() => click()}>
<span class="icon">
<slot name="icon" />
</span>
<slot/>
</button>
{#if href}
<a
class={classes.join(' ')}
title={label}
aria-label={label}
href={href}
on:click={() => click()}>
<span class="icon">
<slot name="icon" />
</span>
<slot/>
</a>
{:else}
<button
type="button"
class={classes.join(' ')}
title={label}
aria-label={label}
on:click={() => click()}>
<span class="icon">
<slot name="icon" />
</span>
<slot/>
</button>
{/if}
<style>
button {
/* min-width: 64px; */
width: 100%;
height: 54px;
padding: 16px;
a, button {
height: fit-content;
padding: .7em .8em;
display: flex;
flex-direction: row;
align-items: center;
@ -60,14 +71,13 @@
font-size: 1rem;
font-weight: 600;
text-align: left;
text-decoration: none;
border-radius: 8px;
border-width: 2px;
border-style: solid;
border: 2px solid var(--bg-700);
background-color: var(--bg-700);
color: var(--text);
border-color: transparent;
transition-property: border-color, background-color, color;
transition-timing-function: ease-out;
@ -75,22 +85,32 @@
cursor: pointer;
}
a {
width: calc(100% - 1.6em);
}
button {
width: 100%;
}
a.centered,
button.centered {
text-align: center;
justify-content: center;
}
a:hover,
button:hover {
background-color: color-mix(in srgb, var(--bg-700), var(--accent) 10%);
border-color: color-mix(in srgb, var(--bg-700), var(--accent) 20%);
border-color: color-mix(in srgb, var(--bg-700), black 10%);
background-color: color-mix(in srgb, var(--bg-700), black 10%);
}
a:active,
button:active {
background-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 50%);
border-color: color-mix(in srgb, var(--bg-700), var(--bg-800) 10%);
border-color: color-mix(in srgb, var(--bg-700), black 20%);
background-color: color-mix(in srgb, var(--bg-700), black 20%);
}
a.active,
button.active {
background-color: var(--bg-600);
color: var(--accent);
@ -98,50 +118,49 @@
text-shadow: 0px 2px 32px var(--accent);
}
a.active:hover,
button.active:hover {
color: color-mix(in srgb, var(--accent), var(--bg-1000) 20%);
border-color: color-mix(in srgb, var(--accent), var(--bg-1000) 20%);
background-color: color-mix(in srgb, var(--bg-600), var(--accent) 10%);
}
a.active:active,
button.active:active {
color: color-mix(in srgb, var(--accent), var(--bg-800) 10%);
border-color: color-mix(in srgb, var(--accent), var(--bg-800) 10%);
background-color: color-mix(in srgb, var(--bg-600), var(--bg-800) 10%);
}
a.filled,
button.filled {
background-color: var(--accent);
color: var(--bg-800);
border-color: transparent;
}
button.filled:hover {
a.filled:not(.disabled):hover,
button.filled:not(.disabled):hover {
color: color-mix(in srgb, var(--bg-800), white 10%);
background-color: color-mix(in srgb, var(--accent), white 20%);
}
button.filled:active {
a.filled:not(.disabled):active,
button.filled:not(.disabled):active {
color: color-mix(in srgb, var(--bg-800), black 10%);
background-color: color-mix(in srgb, var(--accent), black 20%);
}
a.disabled,
button.disabled {
background-color: var(--bg-700);
color: var(--text);
opacity: .5;
opacity: .35;
border-color: transparent;
cursor: initial;
}
button.disabled:hover {
}
button.disabled:active {
cursor: not-allowed;
}
.icon:not(:empty) {
height: 150%;
height: 1.8em;
margin-right: 8px;
}

View file

@ -7,6 +7,7 @@
import { timeline } from '$lib/timeline.js';
import { createEventDispatcher } from 'svelte';
import { playSound } from '$lib/sound';
import Lang from '$lib/lang'
import Button from '@cf/ui/Button.svelte';
import PostIcon from '@cf/icons/post.svg';
@ -19,6 +20,8 @@
import FollowersVisIcon from '@cf/icons/followers.svg';
import PrivateVisIcon from '@cf/icons/dm.svg';
const lang = Lang();
export let reply_id;
let content_warning = ""
@ -26,16 +29,11 @@
// let media_ids = [];
let show_cw = false;
let visibility = "Public";
let visibilityLocale = lang.string('post.visibility.public');
const placeholders = [
"What's cooking, $1?",
"Speak your mind!",
"Federate something...",
"I sure love posting!",
"Another day, another $1 post!",
];
let placeholder = placeholders[Math.floor(placeholders.length * Math.random())]
.replaceAll("$1", $account.username);
const placeholders = lang.stringArray('compose_placeholders');
let placeholder = Array.isArray(placeholders) ? placeholders[Math.floor(placeholders.length * Math.random())]
.replaceAll("%1", $account.username) : placeholders;
const dispatch = createEventDispatcher();
@ -77,15 +75,19 @@
switch (visibility) {
case "Public":
visibility = "Unlisted";
visibilityLocale = lang.string('post.visibility.unlisted');
break;
case "Unlisted":
visibility = "Followers only";
visibilityLocale = lang.string('post.visibility.follow_only');
break;
case "Followers only":
visibility = "Private";
visibilityLocale = lang.string('post.visibility.private');
break;
case "Private":
visibility = "Public";
visibilityLocale = lang.string('post.visibility.public');
break;
}
}
@ -93,7 +95,8 @@
<div class="composer">
<div class="composer-header-container">
<a href={$account.url} target="_blank" class="composer-avatar-container" on:mouseup|stopPropagation>
<!-- TODO: account switcher in composer -->
<a href="" class="composer-avatar-container" on:mouseup|stopPropagation>
<img src={$account.avatar_url} type={$account.avatar_type} alt="" width="48" height="48" class="composer-avatar" loading="lazy" decoding="async">
</a>
<header class="composer-header">
@ -104,7 +107,7 @@
<div class="composer-info" on:mouseup|stopPropagation>
</div>
</header>
<div title={visibility}>
<div title={visibilityLocale}>
<Button centered={true} on:click={() => {cycleVisibility()}}>
<svelte:fragment slot="icon">
<!-- TODO: this should be a drop-down option!...later -->
@ -122,7 +125,7 @@
</div>
</div>
{#if show_cw}
<input type="text" id="" placeholder="Content warning" bind:value={content_warning}/>
<input type="text" id="" placeholder="{lang.string('post.warning.placeholder')}" bind:value={content_warning}/>
{/if}
<textarea placeholder="{placeholder}" class="textbox" bind:value={content}></textarea>
<div class="composer-footer">

View file

@ -3,9 +3,12 @@
import { server, createServer } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { get } from 'svelte/store';
import Lang from '$lib/lang';
import Logo from '$lib/../img/campfire-logo.svg';
const lang = Lang();
let display_error = false;
let logging_in = false;
@ -17,21 +20,21 @@
const host = event.target.host.value;
if (!host || host === "") {
display_error = "Please enter an server domain.";
display_error = lang.string('login.error.no_domain');
logging_in = false;
return;
}
server.set(await createServer(host));
if (!get(server)) {
display_error = "Failed to connect to the server.\nCheck the browser console for details!"
display_error = lang.string('login.error.connection_failed');
logging_in = false;
return;
}
app.set(await api.createApp(get(server).host));
if (!get(app)) {
display_error = "Failed to create an application for this server."
display_error = lang.string('login.error.create_app');
logging_in = false;
return;
}
@ -44,8 +47,8 @@
<div class="app-logo">
<Logo />
</div>
<p>Welcome, fediverse user!</p>
<p>Please enter your server domain to log in.</p>
<p>{lang.string('login.welcome')}</p>
<p>{lang.string('login.enter_domain')}</p>
<div class="input-wrapper">
<input type="text" id="host" aria-label="server domain" class={logging_in ? "throb" : ""}>
{#if display_error}
@ -53,16 +56,10 @@
{/if}
</div>
<br>
<button type="submit" id="login" class={logging_in ? "disabled" : ""}>Log in</button>
<p><small>
Please note this is
<strong><em>extremely experimental software</em></strong>;
things are likely to break!
<br>
If that's all cool with you, welcome aboard!
</small></p>
<button type="submit" id="login" class={logging_in ? "disabled" : ""}>{lang.string('login.button')}</button>
<p><small>{@html lang.string('login.experimental')}</small></p>
<p class="form-footer">made with ❤ by <a href="https://bliss.town">bliss town</a>, 2024</p>
<p class="form-footer">{@html lang.string('login.made_with_tagline')}</p>
</form>
<style>

View file

@ -27,7 +27,7 @@
z-index: 101;
display: flex;
justify-content: center;
position: absolute;
position: fixed;
width: 100vw;
height: 100vh;
pointer-events: none;
@ -38,7 +38,7 @@
z-index: 101;
padding: 16px;
width: 732px;
width: 700px;
border-radius: 8px;
box-shadow: 0px 16px 64px 4px rgba(0,0,0,0.5);
animation: modal_pop_up .15s cubic-bezier(0.22, 1, 0.36, 1);
@ -50,7 +50,7 @@
.overlay {
width: 100vw;
height: 100vw;
position: absolute;
position: fixed;
top: 0;
left: 0;
z-index: 100;

View file

@ -4,62 +4,46 @@
import { server } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { playSound } from '$lib/sound.js';
import { getTimeline } from '$lib/timeline.js';
import { getNotifications } from '$lib/notifications.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { createEventDispatcher } from 'svelte';
import { notifications, unread_notif_count } from '$lib/notifications.js';
import { createEventDispatcher, onMount } from 'svelte';
import { unread_notif_count } from '$lib/notifications.js';
import { fetchFollowRequests, followRequests } from '$lib/followRequests.js'
import Lang from '$lib/lang';
import Logo from '$lib/../img/campfire-logo.svg';
import Button from './Button.svelte';
import TimelineIcon from '../../img/icons/timeline.svg';
import NotificationsIcon from '../../img/icons/notifications.svg';
import ExploreIcon from '../../img/icons/explore.svg';
import ListIcon from '../../img/icons/lists.svg';
import FavouritesIcon from '../../img/icons/like_fill.svg';
import BookmarkIcon from '../../img/icons/bookmark.svg';
import HashtagIcon from '../../img/icons/hashtag.svg';
import PostIcon from '../../img/icons/post.svg';
import InfoIcon from '../../img/icons/info.svg';
import SettingsIcon from '../../img/icons/settings.svg';
import LogoutIcon from '../../img/icons/logout.svg';
import TimelineIcon from '@cf/icons/timeline.svg';
import NotificationsIcon from '@cf/icons/notifications.svg';
import ExploreIcon from '@cf/icons/explore.svg';
import ListIcon from '@cf/icons/lists.svg';
import FavouritesIcon from '@cf/icons/like_fill.svg';
import BookmarkIcon from '@cf/icons/bookmark.svg';
import HashtagIcon from '@cf/icons/hashtag.svg';
import PostIcon from '@cf/icons/post.svg';
import InfoIcon from '@cf/icons/info.svg';
import SettingsIcon from '@cf/icons/settings.svg';
import LogoutIcon from '@cf/icons/logout.svg';
import FollowersIcon from '@cf/icons/followers.svg';
const VERSION = APP_VERSION;
const COMMIT = APP_COMMIT;
const lang = Lang();
const dispatch = createEventDispatcher();
function handle_btn(name) {
function gotoProfile() {
if (!$account) return;
let route;
switch (name) {
case "timeline":
route = "/";
getTimeline(true);
break;
case "notifications":
route = "/notifications";
notifications.set([]);
getNotifications();
break;
case "explore":
case "lists":
case "favourites":
case "bookmarks":
case "hashtags":
default:
return;
}
if (!route) return;
playSound();
window.scrollTo({
top: 0,
behavior: "smooth"
});
goto(route);
goto(`/${$server.host}/${$account.username}`);
}
async function log_out() {
async function logOut() {
if (!confirm("This will log you out. Are you sure?")) return;
const res = await api.revokeToken(
@ -70,7 +54,7 @@
);
if (!res.ok)
console.warn("Token revocation failed! Dumping data anyways");
console.warn(lang.string('logs.token_revoke_failed'));
account.set(false);
app.set(false);
@ -78,6 +62,10 @@
goto("/");
}
onMount(async () => {
await fetchFollowRequests(true)
})
</script>
<div id="navigation">
@ -90,78 +78,94 @@
{#if $account}
<div id="nav-items">
<Button label="Timeline"
on:click={() => handle_btn("timeline")}
href="/")}
active={$page.url.pathname === "/"}>
<svelte:fragment slot="icon">
<TimelineIcon/>
</svelte:fragment>
Timeline
{lang.string('navigation.timeline')}
</Button>
<Button label="Notifications"
on:click={() => handle_btn("notifications")}
href="/notifications"}
active={$page.url.pathname === "/notifications"}>
<svelte:fragment slot="icon">
<NotificationsIcon/>
</svelte:fragment>
Notifications
{lang.string('navigation.notifications')}
{#if $unread_notif_count}
<span class="notification-count">
{$unread_notif_count <= 99 ? $unread_notif_count : "99+"}
</span>
{/if}
</Button>
{#if $followRequests.length > 0}
<Button label="Follow requests"
href="/follow-requests"}
active={$page.url.pathname === "/follow-requests"}>
<svelte:fragment slot="icon">
<FollowersIcon/>
</svelte:fragment>
{lang.string('navigation.follow_requests')}
<span class="notification-count">
{$followRequests.length}
</span>
</Button>
{/if}
<Button label="Explore" disabled>
<svelte:fragment slot="icon">
<ExploreIcon height="auto"/>
</svelte:fragment>
Explore
{lang.string('navigation.explore')}
</Button>
<Button label="Lists" disabled>
<svelte:fragment slot="icon">
<ListIcon/>
</svelte:fragment>
Lists
{lang.string('navigation.lists')}
</Button>
<div class="flex-row">
<Button centered label="Favourites" disabled>
<Button centered
label="{lang.string('navigation.favourites')}"
href="/favourites"}
active={$page.url.pathname === "/favourites"}>
<svelte:fragment slot="icon">
<FavouritesIcon/>
</svelte:fragment>
</Button>
<Button centered label="Bookmarks" disabled>
<Button centered label="{lang.string('navigation.bookmarks')}" disabled>
<svelte:fragment slot="icon">
<BookmarkIcon/>
</svelte:fragment>
</Button>
<Button centered label="Hashtags" disabled>
<Button centered label="{lang.string('navigation.hashtags')}" disabled>
<svelte:fragment slot="icon">
<HashtagIcon/>
</svelte:fragment>
</Button>
</div>
<Button filled label="Post" on:click={() => dispatch("compose")}>
<Button filled label="{lang.string('compose')}" on:click={() => dispatch("compose")}>
<svelte:fragment slot="icon">
<PostIcon/>
</svelte:fragment>
Post
{lang.string('compose')}
</Button>
</div>
<div id="account-items">
<div class="flex-row">
<Button centered label="Profile information" disabled>
<Button centered label="{lang.string('navigation.profile_information')}" disabled>
<svelte:fragment slot="icon">
<InfoIcon/>
</svelte:fragment>
</Button>
<Button centered label="Settings" disabled>
<Button centered label="{lang.string('navigation.settings')}" disabled>
<svelte:fragment slot="icon">
<SettingsIcon/>
</svelte:fragment>
</Button>
<Button centered label="Log out" on:click={() => log_out()}>
<Button centered label="{lang.string('navigation.log_out')}" on:click={() => logOut()}>
<svelte:fragment slot="icon">
<LogoutIcon/>
</svelte:fragment>
@ -169,11 +173,11 @@
</div>
<div id="account-button">
<img src={$account.avatar_url} class="account-avatar" height="64px" alt="" aria-hidden="true" on:click={() => playSound()}>
<img src={$account.avatar_url} class="account-avatar" height="64px" alt="" aria-hidden="true" on:click={() => gotoProfile()}>
<div class="account-name" aria-hidden="true">
<a href={$account.url} class="nickname" title={$account.nickname}>{@html $account.rich_name}</a>
<a href="/{$server.host}/@{$account.username}" class="nickname" title={$account.nickname}>{@html $account.rich_name}</a>
<span class="username" title={`@${$account.username}@${$account.host}`}>
{`@${$account.username}@${$account.host}`}
{$account.fqn}
</span>
</div>
</div>
@ -181,11 +185,11 @@
{/if}
<span class="version">
campfire v{VERSION}
campfire v{VERSION} ({COMMIT})
<br>
<ul>
<li><a href="https://git.arimelody.me/blisstown/campfire">source</a></li>
<li><a href="https://github.com/blisstown/campfire/issues">issues</a></li>
<li><a href="https://git.arimelody.me/blisstown/campfire">{lang.string('source')}</a></li>
<li><a href="https://codeberg.org/arimelody/campfire/issues">{lang.string('issues')}</a></li>
</ul>
</span>
</div>
@ -200,6 +204,8 @@
height: calc(100vh - 32px);
border-radius: 8px;
background-color: var(--bg-800);
transition: background-color .1s linear;
user-select: none;
}
.server-header {
@ -213,6 +219,7 @@
background-size: cover;
background-color: var(--bg-600);
background-image: linear-gradient(to top, var(--bg-800), var(--bg-600));
transition: background .1s linear;
}
.server-icon {
@ -270,6 +277,7 @@
font-size: .9em;
opacity: .6;
text-align: center;
user-select: text;
}
.version ul {

View file

@ -2,17 +2,20 @@
import { server } from '$lib/client/server';
import { goto } from '$app/navigation';
import ReplyIcon from '$lib/../img/icons/reply.svg';
import RepostIcon from '$lib/../img/icons/repost.svg';
import FavouriteIcon from '$lib/../img/icons/like.svg';
import ReactIcon from '$lib/../img/icons/react.svg';
import ReplyIcon from '@cf/icons/reply.svg';
import RepostIcon from '@cf/icons/repost.svg';
import FavouriteIcon from '@cf/icons/like.svg';
import ReactIcon from '@cf/icons/react.svg';
// import QuoteIcon from '$lib/../img/icons/quote.svg';
import ReactionBar from '$lib/ui/post/ReactionBar.svelte';
import ActionBar from '$lib/ui/post/ActionBar.svelte';
import Lang from '$lib/lang';
const lang = Lang();
let mention = (accounts) => {
let res = `<a href=${account.url}>${account.rich_name}</a>`;
if (accounts.length > 1) res += ` and <strong>${accounts.length - 1}</strong> others`;
let res = `<a href="/${$server.host}/${account.fqn}">${account.rich_name}</a>`;
if (accounts.length > 1) res += ' ' + lang.string('notification.and_others').replaceAll('%1', accounts.length - 1);
return res;
};
@ -20,23 +23,23 @@
let activity_text = function (type) {
switch (type) {
case "mention":
return `%1 mentioned you.`;
return lang.string('notification.mention');
case "reblog":
return `%1 boosted your post.`;
return lang.string('notification.reblog');
case "reaction":
return `%1 reacted to your post.`;
return lang.string('notification.reaction');
case "follow":
return `%1 followed you.`;
return lang.string('notification.follow');
case "follow_request":
return `%1 requested to follow you.`;
return lang.string('notification.follow.request');
case "favourite":
return `%1 favourited your post.`;
return lang.string('notification.favourite');
case "poll":
return `%1's poll as ended.`;
return lang.string('notification.poll');
case "update":
return `%1 updated their post.`;
return lang.string('notification.update');
default:
return `%1 poked you!`;
return lang.string('notification.default');
}
}(data.type);
@ -87,7 +90,7 @@
</span>
<span class="notif-avatars">
{#if data.accounts.length == 1}
<a href={data.accounts[0].url} class="notif-avatar">
<a href="/{$server.host}/{data.accounts[0].fqn}" class="notif-avatar">
<img src={data.accounts[0].avatar_url} alt="" width="28" height="28" />
</a>
{:else}
@ -141,18 +144,22 @@
<style>
.notification {
display: block;
margin-bottom: 8px;
border-top: 1px solid color-mix(in srgb, transparent, var(--text) 25%);
padding: 16px;
border-radius: 8px;
background: var(--bg-800);
text-decoration: inherit;
color: inherit;
transition: background-color .1s;
cursor: pointer;
background-color: var(--bg-900);
}
.notification:first-of-type {
border-top: none;
}
.notification:hover {
background-color: color-mix(in srgb, var(--bg-800), black 5%);
background-color: color-mix(in srgb, var(--bg-800), transparent 35%);
}
header {
@ -280,7 +287,7 @@
width: calc(100% - 16px);
margin-bottom: 10px;
padding: 4px 8px;
--warn-bg: color-mix(in srgb, var(--bg-700), var(--accent) 1%);
--warn-bg: color-mix(in srgb, transparent, var(--bg-700) 50%);
background: repeating-linear-gradient(-45deg, transparent, transparent 10px, var(--warn-bg) 10px, var(--warn-bg) 20px);
font-family: inherit;
font-size: inherit;

View file

@ -1,5 +1,11 @@
<script>
import Lang from '$lib/lang';
const lang = Lang();
</script>
<div id="widgets">
<input type="text" id="search" placeholder="Search">
<input type="text" id="search" placeholder="{lang.string('search')}">
</div>
<style>

View file

@ -0,0 +1,36 @@
<script>
export let title;
</script>
<header>
<h1>{title}</h1>
<div class="header-items">
<slot/>
</div>
</header>
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0;
padding: 0 8px;
display: flex;
flex-direction: row;
user-select: none;
box-sizing: border-box;
}
header h1 {
font-size: 1.5em;
}
header .header-items {
margin-left: auto;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
</style>

View file

@ -6,19 +6,22 @@
import { timeline } from '$lib/timeline';
import { parseReactions } from '$lib/post';
import { playSound } from '$lib/sound';
import Lang from '$lib/lang';
import ActionButton from './ActionButton.svelte';
import ReplyIcon from '../../../img/icons/reply.svg';
import RepostIcon from '../../../img/icons/repost.svg';
import FavouriteIcon from '../../../img/icons/like.svg';
import FavouriteIconFill from '../../../img/icons/like_fill.svg';
import QuoteIcon from '../../../img/icons/quote.svg';
import MoreIcon from '../../../img/icons/more.svg';
import DeleteIcon from '../../../img/icons/bin.svg';
import ReplyIcon from '@cf/icons/reply.svg';
import RepostIcon from '@cf/icons/repost.svg';
import FavouriteIcon from '@cf/icons/like.svg';
import FavouriteIconFill from '@cf/icons/like_fill.svg';
import QuoteIcon from '@cf/icons/quote.svg';
import MoreIcon from '@cf/icons/more.svg';
import DeleteIcon from '@cf/icons/bin.svg';
export let post;
const lang = Lang();
async function toggleBoost() {
if (!$app || !$app.token) return;
@ -74,29 +77,29 @@
</script>
<div class="post-actions" aria-label="Post actions" role="toolbar" tabindex="0" on:mouseup|stopPropagation on:keydown|stopPropagation>
<ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>
<ActionButton type="reply" label="{lang.string('post.actions.reply')}" bind:count={post.reply_count} sound="post" disabled>
<ReplyIcon/>
</ActionButton>
<ActionButton type="boost" label="Boost" on:click={toggleBoost} bind:active={post.boosted} bind:count={post.boost_count} disabled={!$account}>
<ActionButton type="boost" label="{lang.string('post.actions.boost')}" on:click={toggleBoost} bind:active={post.boosted} bind:count={post.boost_count} disabled={!$account}>
<RepostIcon/>
<svelte:fragment slot="activeIcon">
<RepostIcon/>
</svelte:fragment>
</ActionButton>
<ActionButton type="favourite" label="Favourite" on:click={toggleFavourite} bind:active={post.favourited} bind:count={post.favourite_count} disabled={!$account}>
<ActionButton type="favourite" label="{lang.string('post.actions.favourite')}" on:click={toggleFavourite} bind:active={post.favourited} bind:count={post.favourite_count} disabled={!$account}>
<FavouriteIcon/>
<svelte:fragment slot="activeIcon">
<FavouriteIconFill/>
</svelte:fragment>
</ActionButton>
<ActionButton type="quote" label="Quote" disabled>
<ActionButton type="quote" label="{lang.string('post.actions.quote')}" disabled>
<QuoteIcon/>
</ActionButton>
<ActionButton type="more" label="More" disabled>
<ActionButton type="more" label="{lang.string('post.actions.more')}" disabled>
<MoreIcon/>
</ActionButton>
{#if $account && post.account.id === $account.id}
<ActionButton type="delete" label="Delete" on:click={deletePost}>
<ActionButton type="delete" label="{lang.string('post.actions.delete')}" on:click={deletePost}>
<DeleteIcon/>
</ActionButton>
{/if}

View file

@ -1,25 +1,29 @@
<script>
import Lang from '$lib/lang';
export let post;
let open_warned = false;
const lang = Lang();
let open = false;
</script>
<div class="post-body">
{#if post.warning}
<button class="post-warning" on:click|stopPropagation={() => { open_warned = !open_warned }} on:mouseup|stopPropagation>
<button class="post-warning" on:click|stopPropagation={() => { open = !open }} on:mouseup|stopPropagation>
<strong>
{post.warning}
<span class="warning-instructions">
{#if !open_warned}
(click to reveal)
<span class="instructions">
{#if !open}
{lang.string('post.warning.show')}
{:else}
(click to hide)
{lang.string('post.warning.hide')}
{/if}
</span>
</strong>
</button>
{/if}
{#if !post.warning || open_warned}
{#if !post.warning || open}
{#if post.rich_text}
<span class="post-text">{@html post.rich_text}</span>
{:else if post.html}
@ -60,7 +64,7 @@
width: 100%;
margin-bottom: 10px;
padding: 4px 8px;
--warn-bg: color-mix(in srgb, var(--bg-700), var(--accent) 1%);
--warn-bg: color-mix(in srgb, transparent, var(--bg-700) 50%);
background: repeating-linear-gradient(-45deg, transparent, transparent 10px, var(--warn-bg) 10px, var(--warn-bg) 20px);
font-family: inherit;
font-size: inherit;
@ -78,13 +82,12 @@
box-shadow: 0 0 8px var(--warn-bg);
}
.post-warning .warning-instructions {
.post-warning .instructions {
font-weight: normal;
opacity: .5;
}
.post-text {
font-size: .9em;
line-height: 1.45em;
word-wrap: break-word;
}
@ -129,7 +132,9 @@
color: var(--accent);
}
.post-text :global(a.mention) {
/* mention gets used in other places (bios) so it's global */
:global(a.mention) {
color: inherit;
font-weight: 600;
padding: 3px 6px;
@ -138,7 +143,7 @@
text-decoration: none;
}
.post-text :global(a.mention:hover) {
:global(a.mention:hover) {
text-decoration: underline;
}

View file

@ -1,5 +1,10 @@
<script>
import { shorthand as short_time } from '$lib/time.js';
import { server } from '$lib/client/server';
import RepostIcon from '@cf/icons/repost.svg';
import Lang from '$lib/lang';
const lang = Lang();
export let post;
@ -7,17 +12,19 @@
</script>
<div class="post-context">
<span class="post-context-icon">🔁</span>
<span class="post-context-icon">
<RepostIcon width="22px" />
</span>
<span class="post-context-action">
<a href={post.account.url} target="_blank"><span class="name">
{@html post.account.rich_name}</span>
</a>
boosted this post.
{ @html
lang.string('post.boosted',
`<a href="/${$server.host}/${post.account.fqn}"><span class="name">${post.account.rich_name}</span></a>`)
}
</span>
<span class="post-context-time">
<time title="{time_string}">{short_time(post.created_at)}</time>
{#if post.visibility !== "public"}
<span class="post-visibility">- {post.visibility}</span>
<span class="post-visibility">- {lang.string(`post.visibility.${post.visibility}`)}</span>
{/if}
</span>
</div>
@ -38,11 +45,13 @@
}
.post-context-icon {
margin-right: 4px;
height: 1em;
margin-right: .1em;
transform: translateY(-.3em);
}
.post-context a,
.post-context a:visited {
:global(.post-context a),
:global(.post-context a:visited) {
color: inherit;
text-decoration: none;
}

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { server } from '$lib/client/server';
import Lang from '$lib/lang';
import BoostContext from './BoostContext.svelte';
import ReplyContext from './ReplyContext.svelte';
@ -12,6 +13,9 @@
export let post_data;
export let focused = false;
export let pinned = false;
const lang = Lang();
let post_context = undefined;
let post = post_data;
@ -41,8 +45,6 @@
window.scrollTo(0, el.scrollHeight);
}
});
let aria_label = post.account.username + '; ' + post.text + '; ' + post.created_at;
</script>
<div class="post-container">
@ -51,12 +53,15 @@
<ReplyContext post={reply} />
{/await}
{/if}
{#if pinned}
<p class="post-context pinned">{lang.string('post.pinned')}</p>
{/if}
{#if is_boost && !post_context.text}
<BoostContext post={post_context} />
{/if}
<article
class={"post" + (focused ? " focused" : "")}
aria-label={aria_label}
aria-label={post.account.username + '; ' + post.text + '; ' + post.created_at}
bind:this={el}
on:mousedown={e => {mouse_pos.left = e.pageX; mouse_pos.top = e.pageY}}
on:mouseup={e => {if (e.pageX == mouse_pos.left && e.pageY == mouse_pos.top) gotoPost(e)}}
@ -74,18 +79,24 @@
<style>
.post-container {
width: 732px;
max-width: 732px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
border-radius: 8px;
background-color: var(--bg-800);
border-top: 1px solid color-mix(in srgb, transparent, var(--text) 20%);
background: var(--bg-900);
}
.post-container:first-of-type {
border-top: none;
}
.pinned {
margin: .9em 1.2em .3em 1.2em;
font-size: .8em;
color: var(--accent);
background-color: inherit;
}
.post {
padding: 16px;
border-radius: 8px;
transition: background-color .1s;
cursor: pointer;
}
@ -100,7 +111,7 @@
}
.post:hover {
background-color: color-mix(in srgb, var(--bg-800), black 5%);
background-color: color-mix(in srgb, transparent, var(--bg-800) 25%);
}
.post-container:has(.post-context) .post {

View file

@ -1,5 +1,9 @@
<script>
import { shorthand as short_time } from '$lib/time.js';
import { server } from '$lib/client/server';
import Lang from '$lib/lang';
const lang = Lang();
export let post;
export let reply = undefined;
@ -8,21 +12,19 @@
</script>
<div class={"post-header-container" + (reply ? " reply" : "")}>
<a href={post.account.url} target="_blank" class="post-avatar-container" on:mouseup|stopPropagation>
<a href="/{$server.host}/@{post.account.fqn}" class="post-avatar-container" on:mouseup|stopPropagation>
<img src={post.account.avatar_url} type={post.account.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<header class="post-header">
<div class="post-user-info" on:mouseup|stopPropagation>
<a href={post.account.url} target="_blank" class="name">{@html post.account.rich_name}</a>
<a href="/{$server.host}/@{post.account.fqn}" class="name">{@html post.account.rich_name}</a>
<span class="username">{post.account.mention}</span>
</div>
<div class="post-info" on:mouseup|stopPropagation>
<a href={post.url} target="_blank" class="created-at">
<time title={time_string}>{short_time(post.created_at)}</time>
{#if post.visibility !== "public"}
<br>
<span class="post-visibility">{post.visibility}</span>
{/if}
<br>
<span class="post-visibility">{lang.string('post.visibility.' + post.visibility)}</span>
</a>
</div>
</header>

View file

@ -51,11 +51,7 @@
{/if}
</ReactionButton>
{/each}
<ReactionButton
type="reaction"
title="react"
label="React"
disabled>
<ReactionButton disabled>
<ReactIcon/>
</ReactionButton>
</div>

View file

@ -1,11 +1,14 @@
<script>
import { playSound } from '../../sound.js';
import { createEventDispatcher } from 'svelte';
import Lang from '$lib/lang';
const dispatch = createEventDispatcher();
const lang = Lang();
export let type = "react";
export let label = "React";
export let title = label;
export let label = lang.string('post.actions.react');
export let title = lang.string('post.actions.react');
export let count = 0;
export let active = false;
export let disabled = false;

View file

@ -61,13 +61,12 @@
flex-direction: row;
color: var(--text);
align-items: stretch;
border-radius: 8px;
transition: background-color .1s;
cursor: pointer;
}
.post-reply:hover {
background-color: color-mix(in srgb, var(--bg-800), black 5%);
background-color: color-mix(in srgb, var(--bg-800), transparent 50%);
}
.post-avatar-container {

View file

@ -1,17 +1,20 @@
<script>
import '$lib/app.css';
import '../app.css';
import * as api from '$lib/api.js';
import { server } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { account } from '$lib/stores/account.js';
import { parseAccount } from '$lib/account.js';
import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js';
import Lang from '$lib/lang';
import Navigation from '$lib/ui/Navigation.svelte';
import Modal from '@cf/ui/Modal.svelte';
import Composer from '@cf/ui/Composer.svelte';
import Widgets from '$lib/ui/Widgets.svelte';
const lang = Lang();
let show_composer = false;
async function init() {
@ -25,7 +28,7 @@
if (!data) return;
account.set(parseAccount(data));
console.log(`Logged in as @${$account.username}@${$account.host}`);
console.log(lang.string('logs.logged_in', $account.fqn));
// spin up async task to fetch notifications
const notif_data = await api.getNotifications(
@ -48,7 +51,7 @@
<main>
{#await init()}
<div class="loading throb">
<span>just a moment...</span>
<span>{lang.string('loading')}</span>
</div>
{:then}
<slot></slot>

View file

@ -2,37 +2,87 @@
import { page } from '$app/stores';
import { account } from '$lib/stores/account.js';
import { timeline, getTimeline } from '$lib/timeline.js';
import { app_name } from '$lib/config.js';
import Lang from '$lib/lang';
import LoginForm from '$lib/ui/LoginForm.svelte';
import Button from '$lib/ui/Button.svelte';
import Post from '$lib/ui/post/Post.svelte';
import PageHeader from '../lib/ui/core/PageHeader.svelte';
const lang = Lang();
// TODO: refactor to enum when moving to TS
let timelineType = localStorage.getItem(app_name + '_selected_timeline') || "home";
$: {
// awful hack to update timeline fresh
// when timelineType is updated
//
// TODO: migrate to $effect when migrating to svelte 5
timelineType = timelineType
// set in localStorage
localStorage.setItem(app_name + '_selected_timeline', timelineType);
// erase the timeline here so the ui reacts instantly
// mae: i could write an awesome undertale reference here
timeline.set([]);
getCurrentTimeline()
}
function getCurrentTimeline(clean = false) {
switch(timelineType) {
case "home":
getTimeline("home", clean);
break;
case "local":
getTimeline("public", clean, true)
break;
case "federated":
getTimeline("public", clean, false, true)
break;
}
}
account.subscribe(account => {
if (account) getTimeline();
if (account) getCurrentTimeline();
});
document.addEventListener("scroll", () => {
document.addEventListener('scroll', () => {
if ($account && $page.url.pathname !== "/") return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getTimeline();
getCurrentTimeline();
}
});
</script>
{#if $account}
<header>
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<PageHeader title={lang.string(`timeline.${timelineType}`)}>
<Button centered
active={(timelineType == "home")}
on:click={() => timelineType = "home"}>
{lang.string('timeline.home')}
</Button>
<Button centered
active={(timelineType == "local")}
on:click={() => timelineType = "local"}>
{lang.string('timeline.local')}
</Button>
<Button centered
active={(timelineType == "federated")}
on:click={() => timelineType = "federated"}>
{lang.string('timeline.federated')}
</Button>
</PageHeader>
<div id="feed" role="feed">
{#if $timeline.length <= 0}
<div class="loading throb">
<span>getting the feed...</span>
<span>{lang.string('timeline.fetching')}</span>
</div>
{/if}
{#each $timeline as post}
@ -44,25 +94,6 @@
{/if}
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
header h1 {
font-size: 1.5em;
}
nav {
margin-left: auto;
display: flex;
flex-direction: row;
gap: 8px;
}
#feed {
margin-bottom: 20vh;
}

View file

@ -1,5 +1,5 @@
export async function load({ params }) {
return {
server_domain: params.server
server_host: params.server
};
}

View file

@ -1,8 +1,10 @@
import { error } from '@sveltejs/kit';
export async function load({ params }) {
return error(404, 'Not Found');
// return {
// account_name: params.account
// };
let handle = params.account;
if (handle.startsWith('@'))
handle = handle.substring(1);
return {
server_host: params.server,
account_handle: handle,
};
}

View file

@ -0,0 +1,330 @@
<script>
import Button from '@cf/ui/Button.svelte';
import HomeIcon from '@cf/icons/unlisted.svg';
import MoreIcon from '@cf/icons/more.svg';
import LockIcon from '@cf/icons/lock.svg';
import BotIcon from '@cf/icons/bot.svg';
import Lang from '$lib/lang';
import * as api from '$lib/api.js';
import { server, createServer } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { parseAccount } from '$lib/account.js';
import { parsePost } from '$lib/post.js';
import { account } from '$lib/stores/account';
import { goto, afterNavigate } from '$app/navigation';
import { base } from '$app/paths';
import Post from '../../../lib/ui/post/Post.svelte';
import { writable } from 'svelte/store';
export let data;
const lang = Lang();
let profile_pinned_posts = writable([]);
let profile_posts_max_id = null;
let profile_posts = writable([]);
let profile = fetchProfile(data.account_handle);
let error = false;
let previous_page = base;
let post_replies = false;
let post_boosts = true;
let post_media = false;
afterNavigate(({from}) => {
previous_page = from?.url.pathname || previous_page;
profile = fetchProfile(data.account_handle);
})
async function getPosts(profile, max_id) {
const posts = await api.getUserPosts(
$server.host,
$app.token,
profile.id,
max_id,
post_replies,
post_boosts,
post_media
);
let parsed_posts = [];
for (let post of posts) {
parsed_posts.push(await parsePost(post, 1));
}
profile_posts.update(posts => {
posts.push(...parsed_posts);
return posts;
});
return parsed_posts.length > 0 ? parsed_posts[parsed_posts.length - 1].id : null;
}
async function fetchProfile(handle) {
let token = $app ? $app.token : null;
profile_posts.set([]);
profile_pinned_posts.set([]);
if (!$server || $server.host !== data.server_host) {
server.set(await createServer(data.server_host));
if (!$server) {
error = lang.string('error.connection_failed', data.server_host);
throw new Error(lang.string('logs.connection_failed', data.server_host));
}
}
let profile_data;
try {
profile_data = await api.lookupUser($server.host, token, handle);
} catch (error) {
throw error;
}
if (!profile_data || profile_data.error) {
error = lang.string('error.profile_fetch_failed_id', handle);
throw new Error(lang.string('logs.profile_fetch_failed_id', handle));
}
let profile = await parseAccount(profile_data, 0);
api.getUserPinnedPosts(
$server.host,
token,
profile.id,
).then(async posts => {
const parsed_posts = [];
for (let post of posts) {
parsed_posts.push(await parsePost(post, 1));
}
profile_pinned_posts.set(parsed_posts);
});
let post_lock = false; // `true` == "locked"
getPosts(profile, null).then(last_id => {
profile_posts_max_id = last_id;
post_lock = false;
});
document.addEventListener("scroll", () => {
if (window.innerHeight + window.scrollY < document.body.offsetHeight - 2048)
return;
if ($profile_posts.length == 0)
return;
if (profile_posts_max_id == null)
return;
if (profile_posts_max_id != $profile_posts[$profile_posts.length - 1].id)
return;
if (post_lock) return;
post_lock = true;
getPosts(profile, profile_posts_max_id).then(last_id => {
profile_posts_max_id = last_id;
post_lock = false;
});
});
return profile;
}
</script>
{#await profile}
<div class="loading throb">
<span>{lang.string('profile.loading')}</span>
</div>
{:then profile}
<header data-banner="{profile.banner_url}">
<img src="{profile.banner_url}" class="profile-banner" alt="">
<div class="profile-tag">
<!-- svelte-ignore a11y-img-redundant-alt -->
<img src="{profile.avatar_url}" alt="">
<div class="profile-tag-names">
<div class="profile-tag-display-name">
<h1>
{@html profile.rich_name}
{#if profile.locked}
<span title="{lang.string('profile.locked')}">
<LockIcon width="22px"/>
</span>
{/if}
{#if profile.bot}
<span title="{lang.string('profile.bot')}">
<BotIcon width="22px"/>
</span>
{/if}
</h1>
</div>
<p>{profile.fqn}</p>
</div>
</div>
</header>
<div class="profile-info">
<p class="profile-bio">{@html profile.rich_bio}</p>
<ul class="profile-counts">
<li><b>{lang.string('profile.followers')}</b> {profile.followers_count}</li>
<li><b>{lang.string('profile.following')}</b> {profile.following_count}</li>
<li><b>{lang.string('profile.posts')}</b> {profile.posts_count}</li>
</ul>
<div class="profile-actions">
{#if $account && profile.fqn !== $account.fqn}
<Button filled disabled label="{lang.string('profile.follow')} {profile.nickname}" class="profile-btn-follow">
{lang.string('profile.follow')}
</Button>
{/if}
<Button label="{lang.string('profile.home_instance')}" href="{profile.url}">
<HomeIcon width="24px"/>
</Button>
<Button>
<MoreIcon width="24px"/>
</Button>
</div>
</div>
<div class="profile-post-categories">
<Button active>
{lang.string('profile.posts')}
</Button>
<Button>
{lang.string('profile.replies')}
</Button>
<Button>
{lang.string('profile.media')}
</Button>
</div>
<div class="profile-pinned-posts">
{#if profile_pinned_posts}
{#each $profile_pinned_posts as post}
<Post post_data={post} pinned />
{/each}
<br/><hr/><br/>
{/if}
</div>
<div class="profile-posts">
{#each $profile_posts as post}
<Post post_data={post} />
{/each}
</div>
{:catch error}
<p class="error">{error}</p>
{/await}
<style>
header {
margin-top: 1rem;
width: 100%;
height: 215px;
position: relative;
}
.profile-banner {
width: 100%;
height: 100%;
object-fit: cover;
background-color: var(--bg-700);
}
.profile-tag {
position: absolute;
bottom: 16px;
left: 16px;
background: color-mix(in srgb, transparent, var(--bg-1000) 50%);
backdrop-filter: blur(8px);
width: fit-content;
display: flex;
height: 64px;
border-radius: 8px;
}
.profile-tag img {
aspect-ratio: 1;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.profile-tag-names {
padding: 8px 16px;
align-self: center;
}
.profile-tag-names * {
margin: 0;
}
.profile-tag-names h1 {
font-size: 1.15rem
}
.profile-tag-names h1 :global(svg) {
height: 1.2em;
width: 1.2em;
margin: -1em -.1em 0 -.1em;
transform: translateY(.2em);
}
.profile-info {
background-color: var(--bg-800);
padding: 16px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.profile-bio {
margin: 0;
/* !! may not be required in prod */
white-space: pre-line;
}
:global(.profile-bio p:first-of-type) {
margin: 0;
}
.profile-counts {
padding: 0;
}
.profile-counts li {
display: inline-block;
}
.profile-counts > *:not(.profile-counts:first-child) {
margin-right: 16px;
}
.profile-actions {
width: fit-content;
display: flex;
align-items: center;
gap: .5rem;
}
.profile-actions :global(button.profile-btn-follow) {
padding: 0 32px;
}
.profile-actions :global(a) {
width: fit-content;
height: 16px;
}
.profile-actions :global(button) {
width: fit-content;
height: 42px;
}
.profile-post-categories {
display: flex;
padding: 1rem 0;
gap: 1rem;
}
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
.error {
padding: 4em 0;
width: 100%;
text-align: center;
}
</style>

View file

@ -1,4 +1,8 @@
export async function load({ params }) {
let handle = params.account;
if (handle.startsWith('@'))
handle = handle.substring(1);
return {
server_host: params.server,
account_handle: params.account,

View file

@ -4,13 +4,16 @@
import { app } from '$lib/client/app.js';
import { parsePost } from '$lib/post.js';
import { goto, afterNavigate } from '$app/navigation';
import { base } from '$app/paths'
import { base } from '$app/paths';
import Lang from '$lib/lang';
import Post from '$lib/ui/post/Post.svelte';
import Button from '$lib/ui/Button.svelte';
export let data;
const lang = Lang();
let post = fetchPost(data.post_id);
let error = false;
let previous_page = base;
@ -30,16 +33,16 @@
// TODO: make `server` a key/value pair to support multiple servers
server.set(await createServer(data.server_host));
if (!$server) {
error = `Failed to connect to <code>${data.server_host}</code>.`;
console.error(`Failed to connect to ${data.server_host}.`);
error = lang.string('error.connection_failed', data.server_host);
console.error(lang.string('logs.connection_failed', data.server_host));
return;
}
}
const post_data = await api.getPost($server.host, token, post_id);
if (!post_data || post_data.error) {
error = `Failed to retrieve post <code>${post_id}</code>.`;
console.error(`Failed to retrieve post ${post_id}.`);
error = lang.string('error.post_fetch_failed_id', post_id);
console.error(lang.string('logs.post_fetch_failed_id', post_id));
return;
}
let post = await parsePost(post_data, 0);
@ -68,7 +71,7 @@
{#await post}
<div class="loading throb">
<span>loading post...</span>
<span>{lang.string('post.loading')}</span>
</div>
{:then post}
{#if error}
@ -77,12 +80,12 @@
<header>
{#if previous_page}
<nav>
<Button centered on:click={() => {goto(previous_page)}}>Back</Button>
<Button centered on:click={() => {goto(previous_page)}}>{lang.string('navigation.back')}</Button>
</nav>
{/if}
<img src={post.account.avatar_url} type={post.account.avatar_type || "image/png"} alt="" width="40" height="40" class="header-avatar" loading="lazy" decoding="async">
<img src={post.account.avatar_url} type={post.account.avatar_type || 'image/png'} alt="" width="40" height="40" class="header-avatar" loading="lazy" decoding="async">
<h1>
Post by {@html post.account.rich_name}
{@html lang.string('post.by', post.account.rich_name)}
</h1>
</header>

View file

@ -8,17 +8,20 @@
import { error } from '@sveltejs/kit';
import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js';
import { account } from '$lib/stores/account.js';
import Lang from '$lib/lang';
export let data;
const lang = Lang();
let auth_code = data.code;
if (!auth_code || !get(server) || !get(app)) {
error(400, { message: "Bad request" });
error(400, { message: lang.string('error.bad_request') });
} else {
api.getToken(get(server).host, get(app).id, get(app).secret, auth_code).then(token => {
if (!token) {
error(400, { message: "Invalid auth code provided" });
error(400, { message: lang.string('error.invalid_auth_code') });
}
app.update(app => {
@ -30,7 +33,7 @@
if (!data) return goto("/");
account.set(parseAccount(data));
console.log(`Logged in as @${get(account).username}@${get(account).host}`);
console.log(lang.string('logs.logged_in', get(account).fqn));
// spin up async task to fetch notifications
return api.getNotifications(

View file

@ -0,0 +1,36 @@
<script>
import { page } from '$app/stores';
import { account } from '@cf/store/account.js';
import { timeline, getTimeline } from '$lib/timeline.js';
import Button from '@cf/ui/Button.svelte';
import Post from '@cf/ui/post/Post.svelte';
import PageHeader from '@cf/ui/core/PageHeader.svelte';
import Lang from '$lib/lang';
const lang = Lang();
if (!$account) goto("/");
getTimeline("favourites", true);
document.addEventListener('scroll', () => {
if ($account && $page.url.pathname !== "/favourites") return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getTimeline("favourites");
}
});
</script>
<PageHeader title={lang.string(`navigation.favourites`)}/>
<div id="feed" role="feed">
{#if $timeline.length <= 0}
<div class="loading throb">
<span>{lang.string('timeline.fetching')}</span>
</div>
{/if}
{#each $timeline as post}
<Post post_data={post} />
{/each}
</div>

View file

@ -0,0 +1,152 @@
<script>
import { followRequests } from '$lib/followRequests.js';
import PageHeader from '../../lib/ui/core/PageHeader.svelte';
import Lang from '$lib/lang';
import {server} from '$lib/client/server';
import {app} from '$lib/client/app';
import Button from '../../lib/ui/Button.svelte';
import * as api from '$lib/api'
import TickIcon from '@cf/icons/tick.svg'
import CrossIcon from '@cf/icons/cross.svg'
import { get } from 'svelte/store';
const lang = Lang();
async function actionRequest(account_id, approved) {
// remove item from array first - this updates the ui and
// makes the interaction more seamless
$followRequests.splice(
$followRequests.indexOf(
$followRequests.find(r => r.id)
),
1
);
// hack: force the state to update now that we just spliced the array
$followRequests = $followRequests
if(approved) {
await api.acceptFollowRequest(
get(server).host,
get(app).token,
account_id
)
} else {
await api.rejectFollowRequest(
get(server).host,
get(app).token,
account_id
)
}
}
// aliases
const acceptRequest = (id) => actionRequest(id, true);
const denyRequest = (id) => actionRequest(id, false);
</script>
<PageHeader title={lang.string('navigation.follow_requests')}/>
{#if $followRequests.length < 1}
<p class="request-zero">{lang.string('follow_requests.none')}</p>
{/if}
<div class="requests-container">
{#each $followRequests as req}
<div class="request">
<a href="/{$server.host}/@{req.fqn}" class="request-avatar-container" on:mouseup|stopPropagation>
<img src={req.avatar_url} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a>
<div class="info">
<div class="request-user-info">
<a href="/{$server.host}/@{req.fqn}" class="name">{@html req.rich_name}</a>
<span class="username">{req.mention}</span>
</div>
</div>
<div class="request-options">
<Button filled title="Yes" on:click={() => acceptRequest(req.id)}>
<TickIcon width="24px"/>
</Button>
<Button title="No" on:click={() => denyRequest(req.id)}>
<CrossIcon width="24px"/>
</Button>
</div>
</div>
{/each}
</div>
<style>
.request {
width: 100%;
display: flex;
flex-direction: row;
background: var(--bg-900);
padding: .5rem;
border-radius: 8px;
}
.request a,
.request a:visited {
color: inherit;
text-decoration: none;
}
.request a:hover {
text-decoration: underline;
}
.request-avatar-container {
margin-right: 12px;
display: flex;
}
.post-avatar {
border-radius: 8px;
}
.info {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.request-user-info {
margin-top: -2px;
display: flex;
flex-direction: column;
justify-content: center;
}
.request-user-info a {
display: block;
}
.request-user-info .username {
opacity: .8;
font-size: .9em;
}
.request-options {
display: flex;
gap: 8px;
}
.request-options :global(button) {
width: fit-content;
height: 100%;
}
.request-zero {
opacity: 0.8;
font-size: 0.95rem;
text-align: center;
}
.requests-container {
display: flex;
flex-direction: column;
gap: .5rem;
}
</style>

View file

@ -4,6 +4,10 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Notification from '$lib/ui/Notification.svelte';
import PageHeader from '../../lib/ui/core/PageHeader.svelte';
import Lang from '$lib/lang';
const lang = Lang();
if (!$account) goto("/");
@ -30,14 +34,12 @@
});
</script>
<header>
<h1>Notifications</h1>
</header>
<PageHeader title={lang.string('navigation.notifications')}/>
<div class="notifications">
{#if $notifications.length === 0}
<div class="loading throb">
<span>fetching notifications...</span>
<span>{lang.string('notification.fetching')}</span>
</div>
{:else}
{#each $notifications as notif}
@ -47,18 +49,6 @@
</div>
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
h1 {
font-size: 1.5em;
}
.loading {
width: 100%;
height: 80vh;

View file

@ -19,9 +19,10 @@ const config = {
name: child_process.execSync('git rev-parse HEAD').toString().trim()
},
alias: {
'@cf/ui/*': "./src/lib/ui",
'@cf/icons/*': "./src/img/icons",
'@cf/store/*': "./src/lib/stores"
'@cf/ui/*': './src/lib/ui',
'@cf/icons/*': './src/img/icons',
'@cf/store/*': './src/lib/stores',
'@cf/lang/*': './src/lang'
}
},
};

View file

@ -3,11 +3,16 @@ import { defineConfig } from 'vite';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import svg from '@poppanator/sveltekit-svg'
import { execSync } from 'child_process';
// get ver from package.json
const packageFile = fileURLToPath(new URL('package.json', import.meta.url));
const packageData = readFileSync(packageFile, 'utf8');
const packageJSON = JSON.parse(packageData);
// get git commit hash
const commitHash = execSync("git rev-parse HEAD")
.toString().trim().slice(0, 10)
export default defineConfig({
plugins: [
@ -15,7 +20,8 @@ export default defineConfig({
svg()
],
define: {
APP_VERSION: JSON.stringify(packageJSON.version)
APP_VERSION: JSON.stringify(packageJSON.version),
APP_COMMIT: `"${commitHash}"`
}
});