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.html b/src/app.html index cb442f9..197516e 100644 --- a/src/app.html +++ b/src/app.html @@ -1,26 +1,27 @@ + + + + + + - - - - - - + + + - - - + + + - - - + %sveltekit.head% + - %sveltekit.head% - - - -
%sveltekit.body%
- - - \ No newline at end of file + + %sveltekit.body% + + 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/HeroImage.svelte b/src/layout/Hero/HeroImage.svelte index 6ab34cf..faa47fd 100644 --- a/src/layout/Hero/HeroImage.svelte +++ b/src/layout/Hero/HeroImage.svelte @@ -14,16 +14,10 @@ } .hero-img { - height: 70vh; + height: max(100vh, 600px); padding: 0.5rem 0.5rem; border-radius: 1.75rem; background-color: var(--surface-seven); user-select: none; } - @media screen and (max-width: 1700px) { - .hero-img { - height: 100vh; - right: 6rem; - } - } diff --git a/src/layout/Hero/HeroSection.svelte b/src/layout/Hero/HeroSection.svelte index 506c255..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; @@ -60,6 +60,7 @@ } .hero { + padding-top: 10vh; display: flex; flex-direction: column; gap: 1rem; @@ -69,27 +70,9 @@ color: var(--primary); } - @media screen and (max-width: 1700px) { + @media screen and (max-width: 1100px) { .hero { - height: 80vh; - } - } - - @media screen and (max-height: 820px) { - .social-buttons { - bottom: initial; - left: initial; - position: initial; - width: initial; - opacity: 100% !important; - } - } - @media screen and (max-width: 1100px) or (min-height: 820px) { - .social-buttons { - transform: translateX(-50%); - width: 100%; - position: absolute; - justify-content: center; + padding-top: initial; } } @@ -100,11 +83,34 @@ } .social-buttons { + left: 50%; + transform: translateX(-50%); justify-content: center; + width: 100%; } .hero { height: initial; } } + + @media screen and (max-width: 1100px) or (min-height: 780px) { + .social-buttons { + transform: translateX(-50%); + width: 90%; + position: absolute; + left: initial; + transform: initial; + } + } + + @media screen and (max-height: 780px) { + .social-buttons { + transform: initial; + left: initial; + position: initial; + width: initial; + opacity: 100% !important; + } + } 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..0df5c65 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 @@ - + {/if} diff --git a/src/lib/components/Gallery.svelte b/src/lib/components/Gallery.svelte new file mode 100644 index 0000000..a7fc8b9 --- /dev/null +++ b/src/lib/components/Gallery.svelte @@ -0,0 +1,82 @@ + + + + +{#if selectedImage} + +{/if} + + diff --git a/src/lib/components/ImageModal.svelte b/src/lib/components/ImageModal.svelte new file mode 100644 index 0000000..987a973 --- /dev/null +++ b/src/lib/components/ImageModal.svelte @@ -0,0 +1,76 @@ + + + + + + + + + diff --git a/src/lib/components/Input.svelte b/src/lib/components/Input.svelte new file mode 100644 index 0000000..ae523c9 --- /dev/null +++ b/src/lib/components/Input.svelte @@ -0,0 +1,65 @@ + + +
    + + +
    + + diff --git a/src/lib/components/Wave.svelte b/src/lib/components/Wave.svelte index bf52e61..bfc2e62 100644 --- a/src/lib/components/Wave.svelte +++ b/src/lib/components/Wave.svelte @@ -22,7 +22,7 @@ height: 40vh; } - @media screen and (max-height: 820px) { + @media screen and (max-height: 780px) { svg { opacity: 0 !important; } diff --git a/src/lib/stores.ts b/src/lib/stores.ts new file mode 100644 index 0000000..0566843 --- /dev/null +++ b/src/lib/stores.ts @@ -0,0 +1,66 @@ +import { readable, writable } from 'svelte/store'; +import { is_logged_in, get_access_token } from './auth'; +import { browser } from '$app/environment'; + +type AdminLoginInfo = + | { + logged_in: true; + expires: number; + logged_in_previously: boolean; + } + | { + logged_in: false; + expires: undefined; + logged_in_previously: boolean; + }; + +const admin_login_info = (): AdminLoginInfo => { + if (is_logged_in()) + return { + logged_in: true, + expires: get_access_token()!.expires, + logged_in_previously: !!get_access_token()?.token + }; + else + return { + logged_in: false, + expires: undefined, + logged_in_previously: !!get_access_token()?.token + }; +}; + +export const admin_login = readable(admin_login_info(), (set) => { + const checkLoginStatus = () => set(admin_login_info()); + + checkLoginStatus(); + + const interval = setInterval(checkLoginStatus, 100); + return () => clearInterval(interval); +}); + +export const read_announcements = writable>(new Set(), (set) => { + if (!browser) return; + + const key = 'read_announcements'; + const data = localStorage.getItem(key); + const parsedArray = data ? JSON.parse(data) : []; + const currentState = new Set(parsedArray); + + const updateStoreState = () => { + set(currentState); + }; + + const handleLocalStorageUpdate = (e: StorageEvent) => { + if (e.key === key) updateStoreState(); + }; + + window.addEventListener('storage', handleLocalStorageUpdate); + updateStoreState(); + + return () => { + window.removeEventListener('storage', handleLocalStorageUpdate); + localStorage.setItem(key, JSON.stringify(Array.from(currentState))); + }; +}); + +export const passed_login_with_creds = writable(false); // will only change when the user INPUTS the credentials, not if the session is just valid diff --git a/src/lib/types.ts b/src/lib/types.ts index 00bc5f3..57ecd84 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,3 +1,19 @@ +export type ResponseAnnouncement = { + archived_at?: string; + attachments?: string[]; + author?: string; + tags?: string[]; + content?: string; + created_at: string; + id: number; + level?: number; + title: string; +}; + +export type Announcement = Omit; + +export type Tags = { name: string }[]; + export interface Contributor { name: string; avatar_url: string; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5bef71a..a09a4ff 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -25,6 +25,7 @@ import { events as themeEvents } from '$util/themeEvents'; import { RV_GOOGLE_TAG_MANAGER_ID } from '$env/static/public'; + import FooterHost from '$layout/Footer/FooterHost.svelte'; const queryClient = new QueryClient({ defaultOptions: { @@ -108,21 +109,19 @@ {/if} + + It's your choice + + We use analytics to improve your experience on this site. By clicking "Allow", you allow us to + collect anonymous data about your visit. + + + + + + - - - It's your choice - - We use analytics to improve your experience on this site. By clicking "Allow", you allow us to - collect anonymous data about your visit. - - - - - - -
    {#if $show_loading_animation} @@ -130,5 +129,5 @@ {/if}
    - +
    diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e03b860..a9d4683 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,7 +1,6 @@ + + +
    + + + + + +
    + {#each filterAnnouncements(data.announcements, displayedTerm, selectedTags) as announcement} + {#if !announcement.archived_at || moment(announcement.archived_at).isAfter(moment())} + {#key selectedTags || displayedTerm} +
    + +
    + {/key} + {/if} + {/each} +
    + +
    (expanded = !expanded)} + on:keypress={() => (expanded = !expanded)} + tabindex="0" + > +

    Archived announcements

    + +
    + +
    +
    + + {#if expanded} +
    + {#each filterAnnouncements(data.announcements, displayedTerm, selectedTags) as announcement} + {#if announcement.archived_at && moment(announcement.archived_at).isBefore(moment())} + {#key selectedTags || displayedTerm} + + {/key} + {/if} + {/each} +
    + {/if} +
    +
    + + diff --git a/src/routes/announcements/AnnouncementBanner.svelte b/src/routes/announcements/AnnouncementBanner.svelte new file mode 100644 index 0000000..3e3d198 --- /dev/null +++ b/src/routes/announcements/AnnouncementBanner.svelte @@ -0,0 +1,65 @@ + + +{#if latestUnreadAnnouncement} + +{/if} diff --git a/src/routes/announcements/AnnouncementCard.svelte b/src/routes/announcements/AnnouncementCard.svelte new file mode 100644 index 0000000..cdda5b7 --- /dev/null +++ b/src/routes/announcements/AnnouncementCard.svelte @@ -0,0 +1,168 @@ + + + + + +
    0} + > + {#if isRead !== undefined && !isRead} + + {/if} + {#if announcement.attachments && announcement.attachments.length > 0} + Banner + {/if} +
    +
    +

    {announcement.title}

    + + {relativeTime(announcement.created_at)} + {#if announcement.archived_at && moment(announcement.archived_at).isBefore(moment())} + + + + {/if} + +
    + +
    +
    +
    + + diff --git a/src/routes/announcements/NewHeader.svelte b/src/routes/announcements/NewHeader.svelte new file mode 100644 index 0000000..47cf806 --- /dev/null +++ b/src/routes/announcements/NewHeader.svelte @@ -0,0 +1,14 @@ +NEW + + diff --git a/src/routes/announcements/TagChip.svelte b/src/routes/announcements/TagChip.svelte new file mode 100644 index 0000000..81d4a15 --- /dev/null +++ b/src/routes/announcements/TagChip.svelte @@ -0,0 +1,64 @@ + + + + + diff --git a/src/routes/announcements/TagsHost.svelte b/src/routes/announcements/TagsHost.svelte new file mode 100644 index 0000000..daebbaa --- /dev/null +++ b/src/routes/announcements/TagsHost.svelte @@ -0,0 +1,87 @@ + + +
    + {#each displayedTags as tag} + handleClick(tag)} + {clickable} + /> + {/each} + + {#if expandable && tags.length > 1} +
  • + +
  • + {/if} +
    + + diff --git a/src/routes/announcements/[slug]/+layout.ts b/src/routes/announcements/[slug]/+layout.ts new file mode 100644 index 0000000..d43d0cd --- /dev/null +++ b/src/routes/announcements/[slug]/+layout.ts @@ -0,0 +1 @@ +export const prerender = false; diff --git a/src/routes/announcements/[slug]/+page.svelte b/src/routes/announcements/[slug]/+page.svelte new file mode 100644 index 0000000..3d744a7 --- /dev/null +++ b/src/routes/announcements/[slug]/+page.svelte @@ -0,0 +1,51 @@ + + +
    + {#if query} + + + + {:else} + + {/if} +
    + +