Compare commits

...
This repository has been archived on 2025-08-26. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.

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(
let timeline_data;
switch(timelineType) {
case "favourites":
timeline_data = await api.getFavourites(
get(server).host,
get(app).token,
"home",
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,35 +5,47 @@
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>
{#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(' ')}
@ -45,13 +57,12 @@
</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}
<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}"`
}
});