From c224285825530561724d00471b45f3cb4405c0a1 Mon Sep 17 00:00:00 2001 From: MadKarma <100418457+madkarmaa@users.noreply.github.com> Date: Tue, 3 Jun 2025 01:12:05 +0200 Subject: [PATCH] feat: Add announcements (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: init /announcements route + update dependecies + add types + add components * fix: use page store to get the query params * feat: filter ann by channel + revert queries lib to v4 * feat: use page store to select channel chip * feat: add content slot * feat: add top row with all channels * feat: add Gallery component * feat: style ChannelChip * feat: add NewChip * feat: add read announcement logic * refactor: Move components to the announcements route folder * feat: Style the announcement cards and use mansory layout * fix: resolve errors * chore: Minor refactor and add more placeholder announcements * fix: Remove unused import * fix: use correct types + import component * fix: Announcements masonry layout not displaying anything * feat: add unread marker to announcement card * refactor: simplify read logic * feat: add gap to channels select * refactor: rename var * feat: hide actions div if not admin * refactor: change gear icon to 3 dots icon * refactor: reduce content preview length * refactor: remove parentheses and newlines * feat: init auth module * refactor: remove newline * feat: add post, patch and delete methods with auth + move get/set token to auth module + add UnauthenticatedError class * feat: add login fn * feat: add login btn * chore: update dependencies to fix vulnerability * refactor: remove card banner * feat: init login functionality in dom * feat: add login request and token storage * refactor: simplify login * refactor: add comment * feat: finish auth in dom * refactor: remove commented code * feat: add code-side digest auth * fix: return initial response if 200 * refactor: remove log * feat: init login modal * feat: more form styles * fix: fix auth header * feat: hide login popup if logged in successfully * feat: animate input label * feat: styling (fix inputs overlap) * fix: add body param to authored requests * feat: add announcement-related helper functions * fix: use ? instead of | undefined * feat: Implement majority of UI * feat: add Input component * fix: fix input font size * feat: add HA1 sha256 to session storage * feat: add login success dialog * fix: fix centered text * fix: use momentjs * fix: fix ts error * feat: set announcement read on click * feat: card check if user is admin * fix: fix svg size * feat: add mobile styling * feat: add session expired modal * fix: close sess exp modal on new login * feat: Search * refactor: Minor cleanup * feat: Replace 'Sudo Login' with 'Admin Login' and remove icon * feat: update api to v4 * refactor: move ApiAnnouncementCreate to types file * fix: minor code fixes * feat: update Announcement type to API v4 spec * feat: add Gallery component * feat: use NewBadge instead of UnreadDot * feat: Prefetch announcements & use new /announcements/id endpoint * refactor: Change all references of channels to tags * refactor: Simplify prefetch query arguments * fix: fix new badge * feat: add gallery to ann page * refactor: change image preview aspect ratio * feat: add fullscreen image transition * feat: move gallery inside announcement container * refactor: use store to check for valid login * refactor: remove unused css class * feat: add editing and deleting announcements TO FIX: `goto('/announcements')` doesn't work * feat: add unsaved changes check * feat: add warning before deleting announcement * refactor: update texts and coloring of warning modal * refactor: remove dot from red coloring * refactor: remove red color * refactor: minor tweaks * fix: fix json parsing errors when there's no content in response * refactor: switch buttons position * feat: add more props to Input * feat: add wrong credentials message * feat: Show tags in announcement cards * feat: Improve NEW header * fix: Don't show 2nd swiggly line if there's no attachments * fix: Attachment border radius on unread announcements * feat: Add create announcement button * fix: Hide tags section in AnnouncementCard when there's no tags * fix: Cleanup tags handling and don't show TagsHost if there's no tags * feat: Add editing and creating announcements and major cleanup * feat: Managing attachments * refactor: Split page into components and decrease repetition * feat: Support modifying createdAt date * fix: Ensure correct default date input value when editing announcements * refactor: Minor cleanup * fix: Save all changes on submit * feat: Managing tags * feat: Managing archived status * refactor: Reduce repetition * fix: Don't show cancel edit button on creation * fix: Disable prerendering announcements to fix building * optimize * feat: add read announcements store + typing fixes * refactor: add newline * fix: add more types * fix: Trying to write to readable and minor cleanup * fix: Leave announcement when clicking announcement in the NavBar * feat: Handle nullability and cleanup * feat: Wrap icon button in Button and cleanup * feat: Relative time within 7d and cleanup * feat: Don't use red text on session expired dialog * refactor: Reduce repetition * refactor: Element -> Input * fix: Reset preview state after cancelling and improve reactivitiy * feat: Improve admin login in settings design * fix: Use Moment.js for date handling to resolve timezone offsets and formatting issues * fix: Don't display content in AnnouncementCard if undefined * fix: Use goto instead of history * feat: Prevent announcement submission without a title * refactor: Minor cleanup * fix: Unable to create attachments * refactor: Use correct type * chore: Remove unused CSS * fix: Improve tag submission handling on Enter key press I'm confused * feat: Improve validity check * feat: Add attachment validation and improve design * fix: Inverted announcement validity check * fix: Empty state for new attachments * fix: Don't overflow ontop of bin icon * fix: Improve invalid new attachment handling * feat: Swap date and content in AnnouncementCard * feat: Complete login on Enter key press in password input * fix: Reversed validation logic * feat: Invalidate cache on Announcement create/delete * fix: Inline icon in Create button to display the correct color * feat: Style AnnouncementCard content * feat: Invalidate cache after save and ensure cache is invalidated after operation * refactor: Use correct Utils location * refactor: Remove unused parameter * feat: Relative date in AnnouncementCard * feat: Improve mobile responsivity * feat: Hide archived announcements for non-admin viewers * feat: Distinguish archived announcements * refactor: update admin panel message Co-authored-by: oSumAtrIX * refactor: update admin panel title Co-authored-by: oSumAtrIX * refactor: Reduce repetition * fix: Update content after saving * feat: Handle nullable content * feat: Archived posts section * fix: Improve nullability handling * refactor: Improve relativeTime configurability * Merge branch 'dev' into pr/madkarmaa/257 * feat: Migrate remaining icons * feat: Use outline icons * feat: Wrap long titles * fix: fix typo * fix: don't cache creds * refactor: remove stale time for announcements * feat: show error alert if admin function fails * feat: Add slugs to announcement URLs for better readability and SEO "A site’s URL structure should be as simple as possible. Consider organizing your content so that URLs are constructed logically and in a manner that is most intelligible to humans." — Google Search Central * feat: add announcement banner * feat: Improve banner designs * fix: Close button color and banner not closing * fix: Sticky navbar, move modals to own components, tidy up CSS, use store for `passed_login_with_creds` * chore: Delete unused icons --------- Co-authored-by: Ushie Co-authored-by: oSumAtrIX --- package-lock.json | 10 + package.json | 1 + pnpm-lock.yaml | 74 ++-- src/app.scss | 16 +- src/data/api/index.ts | 160 +++++++- src/data/api/settings.ts | 2 + src/layout/Footer/FooterHost.svelte | 4 +- src/layout/Hero/HeroSection.svelte | 2 +- src/layout/Navbar/Modals/LoginModal.svelte | 86 +++++ .../Navbar/Modals/LoginSuccessfulModal.svelte | 28 ++ .../Navbar/Modals/SessionExpiredModal.svelte | 35 ++ src/layout/Navbar/Modals/SettingsModal.svelte | 116 ++++++ src/layout/Navbar/NavButton.svelte | 45 ++- src/layout/Navbar/NavHost.svelte | 346 +++++++----------- src/layout/Navbar/StatusBanner.svelte | 17 + src/lib/auth.ts | 134 +++++++ src/lib/components/Banner.svelte | 189 ++++------ src/lib/components/Button.svelte | 96 +++-- src/lib/components/Divider.svelte | 19 + src/lib/components/Gallery.svelte | 82 +++++ src/lib/components/ImageModal.svelte | 76 ++++ src/lib/components/Input.svelte | 65 ++++ src/lib/stores.ts | 66 ++++ src/lib/types.ts | 16 + src/routes/announcements/+page.svelte | 189 ++++++++++ .../announcements/AnnouncementBanner.svelte | 51 +++ .../announcements/AnnouncementCard.svelte | 168 +++++++++ src/routes/announcements/NewHeader.svelte | 14 + src/routes/announcements/TagChip.svelte | 64 ++++ src/routes/announcements/TagsHost.svelte | 87 +++++ src/routes/announcements/[slug]/+layout.ts | 1 + src/routes/announcements/[slug]/+page.svelte | 51 +++ .../announcements/[slug]/AdminButtons.svelte | 152 ++++++++ .../announcements/[slug]/Announcement.svelte | 121 ++++++ .../announcements/[slug]/Attachments.svelte | 154 ++++++++ src/routes/announcements/[slug]/Author.svelte | 38 ++ .../announcements/[slug]/Content.svelte | 112 ++++++ src/routes/announcements/[slug]/Date.svelte | 67 ++++ src/routes/announcements/[slug]/Tags.svelte | 117 ++++++ src/routes/announcements/[slug]/Title.svelte | 41 +++ src/routes/contributors/+page.svelte | 2 +- src/routes/donate/+page.svelte | 4 +- src/routes/download/+page.svelte | 2 +- src/routes/patches/+page.svelte | 61 +-- src/util/debounce.ts | 7 + src/util/filter.ts | 34 ++ src/util/fromNow.ts | 3 + src/util/isValidUrl.ts | 8 + src/util/relativeTime.ts | 7 + 49 files changed, 2759 insertions(+), 481 deletions(-) create mode 100644 src/layout/Navbar/Modals/LoginModal.svelte create mode 100644 src/layout/Navbar/Modals/LoginSuccessfulModal.svelte create mode 100644 src/layout/Navbar/Modals/SessionExpiredModal.svelte create mode 100644 src/layout/Navbar/Modals/SettingsModal.svelte create mode 100644 src/layout/Navbar/StatusBanner.svelte create mode 100644 src/lib/auth.ts create mode 100644 src/lib/components/Divider.svelte create mode 100644 src/lib/components/Gallery.svelte create mode 100644 src/lib/components/ImageModal.svelte create mode 100644 src/lib/components/Input.svelte create mode 100644 src/lib/stores.ts create mode 100644 src/routes/announcements/+page.svelte create mode 100644 src/routes/announcements/AnnouncementBanner.svelte create mode 100644 src/routes/announcements/AnnouncementCard.svelte create mode 100644 src/routes/announcements/NewHeader.svelte create mode 100644 src/routes/announcements/TagChip.svelte create mode 100644 src/routes/announcements/TagsHost.svelte create mode 100644 src/routes/announcements/[slug]/+layout.ts create mode 100644 src/routes/announcements/[slug]/+page.svelte create mode 100644 src/routes/announcements/[slug]/AdminButtons.svelte create mode 100644 src/routes/announcements/[slug]/Announcement.svelte create mode 100644 src/routes/announcements/[slug]/Attachments.svelte create mode 100644 src/routes/announcements/[slug]/Author.svelte create mode 100644 src/routes/announcements/[slug]/Content.svelte create mode 100644 src/routes/announcements/[slug]/Date.svelte create mode 100644 src/routes/announcements/[slug]/Tags.svelte create mode 100644 src/routes/announcements/[slug]/Title.svelte create mode 100644 src/util/debounce.ts create mode 100644 src/util/filter.ts create mode 100644 src/util/fromNow.ts create mode 100644 src/util/isValidUrl.ts create mode 100644 src/util/relativeTime.ts diff --git a/package-lock.json b/package-lock.json index 7484417..359c212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/query-sync-storage-persister": "^4.36.1", "@tanstack/svelte-query": "^4.36.1", "datetrigger": "^1.1.1", + "moment": "^2.30.1", "svelte-material-icons": "^3.0.5" }, "devDependencies": { @@ -3429,6 +3430,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", diff --git a/package.json b/package.json index 864efac..fd3ec23 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@tanstack/query-sync-storage-persister": "^4.36.1", "@tanstack/svelte-query": "^4.36.1", "datetrigger": "^1.1.1", + "moment": "^2.30.1", "svelte-material-icons": "^3.0.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a1c0b7..942a5f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: datetrigger: specifier: ^1.1.1 version: 1.1.1 + moment: + specifier: ^2.30.1 + version: 2.30.1 svelte-material-icons: specifier: ^3.0.5 version: 3.0.5(svelte@4.2.18) @@ -1192,6 +1195,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1241,8 +1247,8 @@ packages: periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - picocolors@1.0.1: - resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} @@ -1276,8 +1282,8 @@ packages: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} - postcss@8.4.41: - resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} + postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -1343,8 +1349,8 @@ packages: resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} engines: {node: '>=6'} - semver@7.6.2: - resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true @@ -1375,8 +1381,8 @@ packages: resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} engines: {node: '>=18'} - source-map-js@1.2.0: - resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} sprintf-js@1.0.3: @@ -2051,7 +2057,7 @@ snapshots: fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.6.2 + semver: 7.6.3 ts-api-utils: 1.3.0(typescript@5.7.2) optionalDependencies: typescript: 5.7.2 @@ -2175,7 +2181,7 @@ snapshots: css-tree@2.3.1: dependencies: mdn-data: 2.0.30 - source-map-js: 1.2.0 + source-map-js: 1.2.1 cssesc@3.0.0: {} @@ -2229,7 +2235,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@9.16.0): dependencies: eslint: 9.16.0 - semver: 7.6.2 + semver: 7.6.3 eslint-config-prettier@9.1.0(eslint@9.16.0): dependencies: @@ -2243,11 +2249,11 @@ snapshots: eslint-compat-utils: 0.5.1(eslint@9.16.0) esutils: 2.0.3 known-css-properties: 0.35.0 - postcss: 8.4.41 - postcss-load-config: 3.1.4(postcss@8.4.41) - postcss-safe-parser: 6.0.0(postcss@8.4.41) + postcss: 8.4.47 + postcss-load-config: 3.1.4(postcss@8.4.47) + postcss-safe-parser: 6.0.0(postcss@8.4.47) postcss-selector-parser: 6.1.2 - semver: 7.6.2 + semver: 7.6.3 svelte-eslint-parser: 0.43.0(svelte@4.2.18) optionalDependencies: svelte: 4.2.18 @@ -2500,6 +2506,8 @@ snapshots: minimist@1.2.8: {} + moment@2.30.1: {} + mri@1.2.0: {} mrmime@2.0.0: {} @@ -2544,35 +2552,35 @@ snapshots: estree-walker: 3.0.3 is-reference: 3.0.2 - picocolors@1.0.1: {} + picocolors@1.1.0: {} picomatch@2.3.1: {} - postcss-load-config@3.1.4(postcss@8.4.41): + postcss-load-config@3.1.4(postcss@8.4.47): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: - postcss: 8.4.41 + postcss: 8.4.47 - postcss-safe-parser@6.0.0(postcss@8.4.41): + postcss-safe-parser@6.0.0(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 - postcss-scss@4.0.9(postcss@8.4.41): + postcss-scss@4.0.9(postcss@8.4.47): dependencies: - postcss: 8.4.41 + postcss: 8.4.47 postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.4.41: + postcss@8.4.47: dependencies: nanoid: 3.3.7 - picocolors: 1.0.1 - source-map-js: 1.2.0 + picocolors: 1.1.0 + source-map-js: 1.2.1 prelude-ls@1.2.1: {} @@ -2629,7 +2637,7 @@ snapshots: dependencies: chokidar: 4.0.3 immutable: 5.0.3 - source-map-js: 1.2.0 + source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.0 @@ -2639,7 +2647,7 @@ snapshots: semiver@1.1.0: {} - semver@7.6.2: {} + semver@7.6.3: {} set-cookie-parser@2.7.0: {} @@ -2647,7 +2655,7 @@ snapshots: dependencies: color: 4.2.3 detect-libc: 2.0.3 - semver: 7.6.2 + semver: 7.6.3 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.4 '@img/sharp-darwin-x64': 0.33.4 @@ -2696,7 +2704,7 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 - source-map-js@1.2.0: {} + source-map-js@1.2.1: {} sprintf-js@1.0.3: {} @@ -2711,7 +2719,7 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 chokidar: 4.0.3 fdir: 6.4.2 - picocolors: 1.0.1 + picocolors: 1.1.0 sade: 1.8.1 svelte: 4.2.18 typescript: 5.7.2 @@ -2723,8 +2731,8 @@ snapshots: eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - postcss: 8.4.41 - postcss-scss: 4.0.9(postcss@8.4.41) + postcss: 8.4.47 + postcss-scss: 4.0.9(postcss@8.4.47) optionalDependencies: svelte: 4.2.18 @@ -2813,7 +2821,7 @@ snapshots: vite@5.3.3(sass@1.81.0): dependencies: esbuild: 0.21.5 - postcss: 8.4.41 + postcss: 8.4.47 rollup: 4.20.0 optionalDependencies: fsevents: 2.3.3 diff --git a/src/app.scss b/src/app.scss index abc8a45..c7a850d 100644 --- a/src/app.scss +++ b/src/app.scss @@ -74,6 +74,7 @@ body { --red-one: hsl(333, 84%, 62%); --red-two: hsl(357, 74%, 60%); + --red-three: hsl(2, 68%, 83%); --yellow-one: hsl(59, 100%, 72%); @@ -86,6 +87,10 @@ body { background-color: var(--tertiary); } +mark { + background-color: var(--secondary); +} + /*-----headings-----*/ h1 { @@ -173,7 +178,13 @@ hr { border-top: 1px solid var(--border); } -input { +textarea { + resize: vertical; + field-sizing: content; +} + +input, +textarea { padding: 1rem; border-radius: 12px; border: 1px solid var(--border); @@ -181,6 +192,7 @@ input { color: var(--secondary); } -input:focus { +input:focus, +textarea:focus { outline: 1px solid var(--primary); } diff --git a/src/data/api/index.ts b/src/data/api/index.ts index a5771fe..d9b2211 100644 --- a/src/data/api/index.ts +++ b/src/data/api/index.ts @@ -9,8 +9,12 @@ import type { DonationPlatform, CryptoWallet, Social, - About + About, + ResponseAnnouncement, + Announcement, + Tags } from '$lib/types'; +import { get_access_token, is_logged_in, UnauthenticatedError } from '$lib/auth'; export type ContributorsData = { contributables: Contributable[] }; export type PatchesData = { patches: Patch[]; packages: string[] }; @@ -19,10 +23,70 @@ export type TeamData = { members: TeamMember[] }; export type AboutData = { about: About }; export type DonationData = { wallets: CryptoWallet[]; platforms: DonationPlatform[] }; export type SocialsData = { socials: Social[] }; +export type AnnouncementsData = { announcements: ResponseAnnouncement[] }; + +type GetAnnouncementsOptions = Partial<{ + tags: string[]; + count: number; + cursor: number; +}>; + +export function build_url(endpoint: string) { + // //////v4/contributors -> v4/contributors + endpoint = endpoint.replace(/^\/+/, ''); + + // v4/contributors -> contributors + if (endpoint.startsWith(settings.API_VERSION)) endpoint = endpoint.split('/').slice(1).join('/'); + + return `${settings.api_base_url()}/${settings.API_VERSION}/${endpoint}`; +} + +function build_headers() { + const access_token_data = get_access_token(); + return { + 'Content-Type': 'application/json', + Authorization: access_token_data ? `Bearer ${access_token_data.token}` : '' + }; +} async function get_json(endpoint: string) { - const url = `${settings.api_base_url()}/${endpoint}`; - return await fetch(url).then((r) => r.json()); + return await fetch(build_url(endpoint)).then((r) => r.json()); +} + +async function post_json(endpoint: string, body?: any) { + if (!is_logged_in()) throw new UnauthenticatedError(); + const headers = build_headers(); + return await fetch(build_url(endpoint), { + method: 'POST', + headers, + body: body ? JSON.stringify(body) : '' + }).then((r) => { + return r.headers.get('content-length') === '0' ? null : r.json(); + }); +} + +async function patch_json(endpoint: string, body?: any) { + if (!is_logged_in()) throw new UnauthenticatedError(); + const headers = build_headers(); + return await fetch(build_url(endpoint), { + method: 'PATCH', + headers, + body: body ? JSON.stringify(body) : '' + }).then((r) => { + return r.headers.get('content-length') === '0' ? null : r.json(); + }); +} + +async function delete_json(endpoint: string, body?: any) { + if (!is_logged_in()) throw new UnauthenticatedError(); + const headers = build_headers(); + return await fetch(build_url(endpoint), { + method: 'DELETE', + headers, + body: body ? JSON.stringify(body) : '' + }).then((r) => { + return r.headers.get('content-length') === '0' ? null : r.json(); + }); } async function contributors(): Promise { @@ -74,6 +138,58 @@ async function about(): Promise { return { about: json }; } +async function announcements(options: GetAnnouncementsOptions = {}): Promise { + const url = new URL(build_url('announcements')); + + if (options.tags && options.tags.length > 0) url.searchParams.set('tags', options.tags.join(',')); + if (options.count) url.searchParams.set('count', String(options.count)); + if (options.cursor) url.searchParams.set('cursor', String(options.cursor)); + + const announcements = (await get_json('announcements')) as ResponseAnnouncement[]; + + return { announcements }; +} + +async function get_announcement_by_id(id: number): Promise<{ announcement: ResponseAnnouncement }> { + return { announcement: (await get_json(`announcements/${id}`)) as ResponseAnnouncement }; +} + +async function announcementTags(): Promise<{ tags: Tags }> { + return { tags: (await get_json(`announcements/tags`)) as Tags }; +} + +function show_error_alert(res: Response) { + alert(`A ${res.status < 500 ? 'user' : 'server'} error occurred. Please try again.`); +} + +export async function create_announcement(announcement: Announcement) { + await post_json('announcements', announcement).catch(show_error_alert); +} + +export async function update_announcement(id: number, announcement: Announcement) { + await patch_json(`announcements/${id}`, announcement).catch(show_error_alert); +} + +export async function delete_announcement(id: number) { + await delete_json(`announcements/${id}`).catch(show_error_alert); +} + +export async function archive_announcement(id: number) { + await post_json(`announcements/${id}/archive`).catch(show_error_alert); +} + +export async function unarchive_announcement(id: number) { + await post_json(`announcements/${id}/unarchive`).catch(show_error_alert); +} + +export const admin = { + create_announcement, + update_announcement, + delete_announcement, + archive_announcement, + unarchive_announcement +}; + async function ping(): Promise { try { const res = await fetch(`${settings.api_base_url()}/v4/ping`, { method: 'HEAD' }); @@ -85,34 +201,48 @@ async function ping(): Promise { export const staleTime = 5 * 60 * 1000; export const queries = { - manager: { + manager: () => ({ queryKey: ['manager'], queryFn: manager, staleTime - }, - patches: { + }), + patches: () => ({ queryKey: ['patches'], queryFn: patches, staleTime - }, - contributors: { + }), + contributors: () => ({ queryKey: ['contributors'], queryFn: contributors, staleTime - }, - team: { + }), + team: () => ({ queryKey: ['team'], queryFn: team, staleTime - }, - about: { + }), + about: () => ({ queryKey: ['info'], queryFn: about, staleTime - }, - ping: { + }), + announcements: () => ({ + queryKey: ['announcements'], + queryFn: () => announcements(), + staleTime + }), + announcementById: (id: number) => ({ + queryKey: ['announcementById', id], + queryFn: () => get_announcement_by_id(id) + }), + announcementTags: () => ({ + queryKey: ['announcementTags'], + queryFn: () => announcementTags(), + staleTime + }), + ping: () => ({ queryKey: ['ping'], queryFn: ping, staleTime - } + }) }; diff --git a/src/data/api/settings.ts b/src/data/api/settings.ts index 0b81e7f..01dd93d 100644 --- a/src/data/api/settings.ts +++ b/src/data/api/settings.ts @@ -17,6 +17,8 @@ function set_status_url(apiUrl: string) { }); } +export const API_VERSION = 'v4'; + // Get base URL export function api_base_url(): string { if (browser) { diff --git a/src/layout/Footer/FooterHost.svelte b/src/layout/Footer/FooterHost.svelte index 3551ef6..46efef7 100644 --- a/src/layout/Footer/FooterHost.svelte +++ b/src/layout/Footer/FooterHost.svelte @@ -7,10 +7,10 @@ import Query from '$lib/components/Query.svelte'; import FooterSection from './FooterSection.svelte'; - import { RV_DMCA_GUID } from '$env/static/public'; import { onMount } from 'svelte'; - const aboutQuery = createQuery(['about'], queries.about); + + const aboutQuery = createQuery(queries.about()); let location: string; diff --git a/src/layout/Hero/HeroSection.svelte b/src/layout/Hero/HeroSection.svelte index 3133b8e..69ebb12 100644 --- a/src/layout/Hero/HeroSection.svelte +++ b/src/layout/Hero/HeroSection.svelte @@ -7,7 +7,7 @@ import Button from '$lib/components/Button.svelte'; import SocialButton from './SocialButton.svelte'; - const aboutQuery = createQuery(['about'], queries.about); + const aboutQuery = createQuery(queries.about()); export let socialsVisibility = true; diff --git a/src/layout/Navbar/Modals/LoginModal.svelte b/src/layout/Navbar/Modals/LoginModal.svelte new file mode 100644 index 0000000..70f2119 --- /dev/null +++ b/src/layout/Navbar/Modals/LoginModal.svelte @@ -0,0 +1,86 @@ + + + +
+

Login

+

This login is reserved for site administrators. Go back!

+ {#if wrong_credentials} +

Username or password do not match. Try again.

+ {/if} +
+
+ + event.key === 'Enter' && loginForm.requestSubmit()} + required + /> +
+
+
+ + + + + +
+ + diff --git a/src/layout/Navbar/Modals/LoginSuccessfulModal.svelte b/src/layout/Navbar/Modals/LoginSuccessfulModal.svelte new file mode 100644 index 0000000..0df09b1 --- /dev/null +++ b/src/layout/Navbar/Modals/LoginSuccessfulModal.svelte @@ -0,0 +1,28 @@ + + + + Successfully logged in! + + + + + + + diff --git a/src/layout/Navbar/Modals/SessionExpiredModal.svelte b/src/layout/Navbar/Modals/SessionExpiredModal.svelte new file mode 100644 index 0000000..d553d01 --- /dev/null +++ b/src/layout/Navbar/Modals/SessionExpiredModal.svelte @@ -0,0 +1,35 @@ + + + + Expired session +
+ This session has expired, log in again to renew or lose all access to administrative power. +
+ + + + +
+ + diff --git a/src/layout/Navbar/Modals/SettingsModal.svelte b/src/layout/Navbar/Modals/SettingsModal.svelte new file mode 100644 index 0000000..eda7800 --- /dev/null +++ b/src/layout/Navbar/Modals/SettingsModal.svelte @@ -0,0 +1,116 @@ + + + + + + + Settings +
+

Configure the API for this website.

+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ + diff --git a/src/layout/Navbar/NavButton.svelte b/src/layout/Navbar/NavButton.svelte index 7138bb2..5144649 100644 --- a/src/layout/Navbar/NavButton.svelte +++ b/src/layout/Navbar/NavButton.svelte @@ -14,12 +14,12 @@ if (queryKey !== null) { if (Array.isArray(queryKey)) { queryKey.forEach((key) => { - const query = queries[key]; + const query = (queries[key] as Function)(); dev_log('Prefetching', query); client.prefetchQuery(query as any); }); } else { - const query = queries[queryKey]; + const query = (queries[queryKey] as Function)(); dev_log('Prefetching', query); client.prefetchQuery(query as any); } @@ -27,20 +27,40 @@ } -
  • +
  • -
  • - diff --git a/src/layout/Navbar/StatusBanner.svelte b/src/layout/Navbar/StatusBanner.svelte new file mode 100644 index 0000000..ccc90e7 --- /dev/null +++ b/src/layout/Navbar/StatusBanner.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..1dcaa65 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,134 @@ +import { browser } from '$app/environment'; +import { build_url } from '$data/api'; + +export type AuthToken = { + token: string; + expires: number; +}; + +type JwtPayload = { + exp: number; + iss: string; + iat: number; +}; + +export class UnauthenticatedError extends Error { + constructor() { + super('Unauthenticated. Cannot perform admin operations.'); + } +} + +// Get access token. +export function get_access_token(): AuthToken | null { + if (!browser) return null; + const data = localStorage.getItem('revanced_api_access_token'); + if (data) return JSON.parse(data) as AuthToken; + return null; +} + +// (Re)set access token. +export function set_access_token(token?: AuthToken) { + if (!token) localStorage.removeItem('revanced_api_access_token'); + else localStorage.setItem('revanced_api_access_token', JSON.stringify(token)); +} + +// Parse a JWT token +export function parseJwt(token: string): JwtPayload { + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + return JSON.parse(jsonPayload) as JwtPayload; +} + +// Check if the admin is authenticated +export function is_logged_in(): boolean { + const token = get_access_token(); + if (!token) return false; + return Date.now() < token.expires; +} + +async function digest_fetch( + url: string, + username: string, + password: string, + options: RequestInit = {} +): Promise { + // Helper function to convert ArrayBuffer to Hex string + function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + } + + // Generate SHA-256 digest + async function sha256(message: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return bufferToHex(hashBuffer); + } + + // Perform an initial request to get the `WWW-Authenticate` header + const initialResponse = await fetch(url, { + method: options.method || 'GET', + headers: options.headers || {} + }); + + if (!initialResponse.ok && initialResponse.status !== 401) + throw new Error(`Initial request failed with status: ${initialResponse.status}`); + + if (initialResponse.ok && initialResponse.status === 200) return initialResponse; + + const authHeader = initialResponse.headers.get('Www-Authenticate'); + if (!authHeader || !authHeader.startsWith('Digest ')) + throw new Error('No Digest authentication header found'); + + // Parse the `WWW-Authenticate` header to extract the fields + const authParams = authHeader + .replace('Digest ', '') + .split(',') + .reduce((acc: Record, item) => { + const [key, value] = item.trim().split('='); + acc[key] = value.replace(/"/g, ''); + return acc; + }, {}); + + const { realm, nonce, algorithm } = authParams; + const method = options.method || 'GET'; + const uri = new URL(url).pathname; + + // https://ktor.io/docs/server-digest-auth.html#flow + const HA1 = await sha256(`${username}:${realm}:${password}`); + const HA2 = await sha256(`${method}:${uri}`); + + const responseHash = await sha256(`${HA1}:${nonce}:${HA2}`); + + // Build the Authorization header + const authHeaderDigest = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm=${algorithm}, response="${responseHash}"`; + + // Perform the final request with the Authorization header + const finalResponse = await fetch(url, { + ...options, + headers: { + ...options.headers, + Authorization: authHeaderDigest + } + }); + + return finalResponse; +} + +export async function login(username: string, password: string) { + const res = await digest_fetch(build_url('token'), username, password); + if (!res.ok) return false; + + const data = await res.json(); + const payload = parseJwt(data.token); + set_access_token({ token: data.token, expires: payload.exp * 1000 }); + return true; +} diff --git a/src/lib/components/Banner.svelte b/src/lib/components/Banner.svelte index d841da5..f87bd1a 100644 --- a/src/lib/components/Banner.svelte +++ b/src/lib/components/Banner.svelte @@ -1,160 +1,105 @@ -