chore: Merge branch dev to main (#293)

This commit is contained in:
Ushie
2025-06-06 02:42:20 +03:00
committed by GitHub
55 changed files with 2872 additions and 577 deletions

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@tanstack/query-sync-storage-persister": "^4.36.1", "@tanstack/query-sync-storage-persister": "^4.36.1",
"@tanstack/svelte-query": "^4.36.1", "@tanstack/svelte-query": "^4.36.1",
"datetrigger": "^1.1.1", "datetrigger": "^1.1.1",
"moment": "^2.30.1",
"svelte-material-icons": "^3.0.5" "svelte-material-icons": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {
@@ -3429,6 +3430,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",

View File

@@ -46,6 +46,7 @@
"@tanstack/query-sync-storage-persister": "^4.36.1", "@tanstack/query-sync-storage-persister": "^4.36.1",
"@tanstack/svelte-query": "^4.36.1", "@tanstack/svelte-query": "^4.36.1",
"datetrigger": "^1.1.1", "datetrigger": "^1.1.1",
"moment": "^2.30.1",
"svelte-material-icons": "^3.0.5" "svelte-material-icons": "^3.0.5"
} }
} }

74
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
datetrigger: datetrigger:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
moment:
specifier: ^2.30.1
version: 2.30.1
svelte-material-icons: svelte-material-icons:
specifier: ^3.0.5 specifier: ^3.0.5
version: 3.0.5(svelte@4.2.18) version: 3.0.5(svelte@4.2.18)
@@ -1192,6 +1195,9 @@ packages:
minimist@1.2.8: minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
mri@1.2.0: mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1241,8 +1247,8 @@ packages:
periscopic@3.1.0: periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
picocolors@1.0.1: picocolors@1.1.0:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
picomatch@2.3.1: picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
@@ -1276,8 +1282,8 @@ packages:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'} engines: {node: '>=4'}
postcss@8.4.41: postcss@8.4.47:
resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==} resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1: prelude-ls@1.2.1:
@@ -1343,8 +1349,8 @@ packages:
resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==}
engines: {node: '>=6'} engines: {node: '>=6'}
semver@7.6.2: semver@7.6.3:
resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
@@ -1375,8 +1381,8 @@ packages:
resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==}
engines: {node: '>=18'} engines: {node: '>=18'}
source-map-js@1.2.0: source-map-js@1.2.1:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
sprintf-js@1.0.3: sprintf-js@1.0.3:
@@ -2051,7 +2057,7 @@ snapshots:
fast-glob: 3.3.2 fast-glob: 3.3.2
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 9.0.5 minimatch: 9.0.5
semver: 7.6.2 semver: 7.6.3
ts-api-utils: 1.3.0(typescript@5.7.2) ts-api-utils: 1.3.0(typescript@5.7.2)
optionalDependencies: optionalDependencies:
typescript: 5.7.2 typescript: 5.7.2
@@ -2175,7 +2181,7 @@ snapshots:
css-tree@2.3.1: css-tree@2.3.1:
dependencies: dependencies:
mdn-data: 2.0.30 mdn-data: 2.0.30
source-map-js: 1.2.0 source-map-js: 1.2.1
cssesc@3.0.0: {} cssesc@3.0.0: {}
@@ -2229,7 +2235,7 @@ snapshots:
eslint-compat-utils@0.5.1(eslint@9.16.0): eslint-compat-utils@0.5.1(eslint@9.16.0):
dependencies: dependencies:
eslint: 9.16.0 eslint: 9.16.0
semver: 7.6.2 semver: 7.6.3
eslint-config-prettier@9.1.0(eslint@9.16.0): eslint-config-prettier@9.1.0(eslint@9.16.0):
dependencies: dependencies:
@@ -2243,11 +2249,11 @@ snapshots:
eslint-compat-utils: 0.5.1(eslint@9.16.0) eslint-compat-utils: 0.5.1(eslint@9.16.0)
esutils: 2.0.3 esutils: 2.0.3
known-css-properties: 0.35.0 known-css-properties: 0.35.0
postcss: 8.4.41 postcss: 8.4.47
postcss-load-config: 3.1.4(postcss@8.4.41) postcss-load-config: 3.1.4(postcss@8.4.47)
postcss-safe-parser: 6.0.0(postcss@8.4.41) postcss-safe-parser: 6.0.0(postcss@8.4.47)
postcss-selector-parser: 6.1.2 postcss-selector-parser: 6.1.2
semver: 7.6.2 semver: 7.6.3
svelte-eslint-parser: 0.43.0(svelte@4.2.18) svelte-eslint-parser: 0.43.0(svelte@4.2.18)
optionalDependencies: optionalDependencies:
svelte: 4.2.18 svelte: 4.2.18
@@ -2500,6 +2506,8 @@ snapshots:
minimist@1.2.8: {} minimist@1.2.8: {}
moment@2.30.1: {}
mri@1.2.0: {} mri@1.2.0: {}
mrmime@2.0.0: {} mrmime@2.0.0: {}
@@ -2544,35 +2552,35 @@ snapshots:
estree-walker: 3.0.3 estree-walker: 3.0.3
is-reference: 3.0.2 is-reference: 3.0.2
picocolors@1.0.1: {} picocolors@1.1.0: {}
picomatch@2.3.1: {} 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: dependencies:
lilconfig: 2.1.0 lilconfig: 2.1.0
yaml: 1.10.2 yaml: 1.10.2
optionalDependencies: 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: 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: dependencies:
postcss: 8.4.41 postcss: 8.4.47
postcss-selector-parser@6.1.2: postcss-selector-parser@6.1.2:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
util-deprecate: 1.0.2 util-deprecate: 1.0.2
postcss@8.4.41: postcss@8.4.47:
dependencies: dependencies:
nanoid: 3.3.7 nanoid: 3.3.7
picocolors: 1.0.1 picocolors: 1.1.0
source-map-js: 1.2.0 source-map-js: 1.2.1
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
@@ -2629,7 +2637,7 @@ snapshots:
dependencies: dependencies:
chokidar: 4.0.3 chokidar: 4.0.3
immutable: 5.0.3 immutable: 5.0.3
source-map-js: 1.2.0 source-map-js: 1.2.1
optionalDependencies: optionalDependencies:
'@parcel/watcher': 2.5.0 '@parcel/watcher': 2.5.0
@@ -2639,7 +2647,7 @@ snapshots:
semiver@1.1.0: {} semiver@1.1.0: {}
semver@7.6.2: {} semver@7.6.3: {}
set-cookie-parser@2.7.0: {} set-cookie-parser@2.7.0: {}
@@ -2647,7 +2655,7 @@ snapshots:
dependencies: dependencies:
color: 4.2.3 color: 4.2.3
detect-libc: 2.0.3 detect-libc: 2.0.3
semver: 7.6.2 semver: 7.6.3
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.4 '@img/sharp-darwin-arm64': 0.33.4
'@img/sharp-darwin-x64': 0.33.4 '@img/sharp-darwin-x64': 0.33.4
@@ -2696,7 +2704,7 @@ snapshots:
mrmime: 2.0.0 mrmime: 2.0.0
totalist: 3.0.1 totalist: 3.0.1
source-map-js@1.2.0: {} source-map-js@1.2.1: {}
sprintf-js@1.0.3: {} sprintf-js@1.0.3: {}
@@ -2711,7 +2719,7 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
chokidar: 4.0.3 chokidar: 4.0.3
fdir: 6.4.2 fdir: 6.4.2
picocolors: 1.0.1 picocolors: 1.1.0
sade: 1.8.1 sade: 1.8.1
svelte: 4.2.18 svelte: 4.2.18
typescript: 5.7.2 typescript: 5.7.2
@@ -2723,8 +2731,8 @@ snapshots:
eslint-scope: 7.2.2 eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
espree: 9.6.1 espree: 9.6.1
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)
optionalDependencies: optionalDependencies:
svelte: 4.2.18 svelte: 4.2.18
@@ -2813,7 +2821,7 @@ snapshots:
vite@5.3.3(sass@1.81.0): vite@5.3.3(sass@1.81.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
postcss: 8.4.41 postcss: 8.4.47
rollup: 4.20.0 rollup: 4.20.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3

View File

@@ -1,10 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" /> <meta
name="robots"
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
/>
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -17,10 +19,9 @@
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body>
<div>%sveltekit.body%</div>
</body>
<body>
%sveltekit.body%
</body>
</html> </html>

View File

@@ -74,6 +74,7 @@ body {
--red-one: hsl(333, 84%, 62%); --red-one: hsl(333, 84%, 62%);
--red-two: hsl(357, 74%, 60%); --red-two: hsl(357, 74%, 60%);
--red-three: hsl(2, 68%, 83%);
--yellow-one: hsl(59, 100%, 72%); --yellow-one: hsl(59, 100%, 72%);
@@ -86,6 +87,10 @@ body {
background-color: var(--tertiary); background-color: var(--tertiary);
} }
mark {
background-color: var(--secondary);
}
/*-----headings-----*/ /*-----headings-----*/
h1 { h1 {
@@ -173,7 +178,13 @@ hr {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
input { textarea {
resize: vertical;
field-sizing: content;
}
input,
textarea {
padding: 1rem; padding: 1rem;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -181,6 +192,7 @@ input {
color: var(--secondary); color: var(--secondary);
} }
input:focus { input:focus,
textarea:focus {
outline: 1px solid var(--primary); outline: 1px solid var(--primary);
} }

View File

@@ -9,8 +9,12 @@ import type {
DonationPlatform, DonationPlatform,
CryptoWallet, CryptoWallet,
Social, Social,
About About,
ResponseAnnouncement,
Announcement,
Tags
} from '$lib/types'; } from '$lib/types';
import { get_access_token, is_logged_in, UnauthenticatedError } from '$lib/auth';
export type ContributorsData = { contributables: Contributable[] }; export type ContributorsData = { contributables: Contributable[] };
export type PatchesData = { patches: Patch[]; packages: string[] }; export type PatchesData = { patches: Patch[]; packages: string[] };
@@ -19,10 +23,70 @@ export type TeamData = { members: TeamMember[] };
export type AboutData = { about: About }; export type AboutData = { about: About };
export type DonationData = { wallets: CryptoWallet[]; platforms: DonationPlatform[] }; export type DonationData = { wallets: CryptoWallet[]; platforms: DonationPlatform[] };
export type SocialsData = { socials: Social[] }; 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) { async function get_json(endpoint: string) {
const url = `${settings.api_base_url()}/${endpoint}`; return await fetch(build_url(endpoint)).then((r) => r.json());
return await fetch(url).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<ContributorsData> { async function contributors(): Promise<ContributorsData> {
@@ -74,6 +138,58 @@ async function about(): Promise<AboutData> {
return { about: json }; return { about: json };
} }
async function announcements(options: GetAnnouncementsOptions = {}): Promise<AnnouncementsData> {
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<boolean> { async function ping(): Promise<boolean> {
try { try {
const res = await fetch(`${settings.api_base_url()}/v4/ping`, { method: 'HEAD' }); const res = await fetch(`${settings.api_base_url()}/v4/ping`, { method: 'HEAD' });
@@ -85,34 +201,48 @@ async function ping(): Promise<boolean> {
export const staleTime = 5 * 60 * 1000; export const staleTime = 5 * 60 * 1000;
export const queries = { export const queries = {
manager: { manager: () => ({
queryKey: ['manager'], queryKey: ['manager'],
queryFn: manager, queryFn: manager,
staleTime staleTime
}, }),
patches: { patches: () => ({
queryKey: ['patches'], queryKey: ['patches'],
queryFn: patches, queryFn: patches,
staleTime staleTime
}, }),
contributors: { contributors: () => ({
queryKey: ['contributors'], queryKey: ['contributors'],
queryFn: contributors, queryFn: contributors,
staleTime staleTime
}, }),
team: { team: () => ({
queryKey: ['team'], queryKey: ['team'],
queryFn: team, queryFn: team,
staleTime staleTime
}, }),
about: { about: () => ({
queryKey: ['info'], queryKey: ['info'],
queryFn: about, queryFn: about,
staleTime 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'], queryKey: ['ping'],
queryFn: ping, queryFn: ping,
staleTime staleTime
} })
}; };

View File

@@ -17,6 +17,8 @@ function set_status_url(apiUrl: string) {
}); });
} }
export const API_VERSION = 'v4';
// Get base URL // Get base URL
export function api_base_url(): string { export function api_base_url(): string {
if (browser) { if (browser) {

View File

@@ -7,10 +7,10 @@
import Query from '$lib/components/Query.svelte'; import Query from '$lib/components/Query.svelte';
import FooterSection from './FooterSection.svelte'; import FooterSection from './FooterSection.svelte';
import { RV_DMCA_GUID } from '$env/static/public'; import { RV_DMCA_GUID } from '$env/static/public';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
const aboutQuery = createQuery(['about'], queries.about);
const aboutQuery = createQuery(queries.about());
let location: string; let location: string;

View File

@@ -14,16 +14,10 @@
} }
.hero-img { .hero-img {
height: 70vh; height: max(100vh, 600px);
padding: 0.5rem 0.5rem; padding: 0.5rem 0.5rem;
border-radius: 1.75rem; border-radius: 1.75rem;
background-color: var(--surface-seven); background-color: var(--surface-seven);
user-select: none; user-select: none;
} }
@media screen and (max-width: 1700px) {
.hero-img {
height: 100vh;
right: 6rem;
}
}
</style> </style>

View File

@@ -7,7 +7,7 @@
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import SocialButton from './SocialButton.svelte'; import SocialButton from './SocialButton.svelte';
const aboutQuery = createQuery(['about'], queries.about); const aboutQuery = createQuery(queries.about());
export let socialsVisibility = true; export let socialsVisibility = true;
</script> </script>
@@ -60,6 +60,7 @@
} }
.hero { .hero {
padding-top: 10vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
@@ -69,27 +70,9 @@
color: var(--primary); color: var(--primary);
} }
@media screen and (max-width: 1700px) { @media screen and (max-width: 1100px) {
.hero { .hero {
height: 80vh; padding-top: initial;
}
}
@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;
} }
} }
@@ -100,11 +83,34 @@
} }
.social-buttons { .social-buttons {
left: 50%;
transform: translateX(-50%);
justify-content: center; justify-content: center;
width: 100%;
} }
.hero { .hero {
height: initial; 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;
}
}
</style> </style>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import { login } from '$lib/auth';
import Input from '$lib/components/Input.svelte';
import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Dialogue.svelte';
import { passed_login_with_creds } from '$lib/stores';
export let modalOpen: boolean;
let loginForm: HTMLFormElement;
let wrong_credentials = false;
async function handle_login(e: SubmitEvent) {
const data = new FormData(e.target as HTMLFormElement);
const username = data.get('username') as string;
const password = data.get('password') as string;
const success = await login(username, password);
modalOpen = !success;
console.log(success);
passed_login_with_creds.set(success);
wrong_credentials = !success;
}
</script>
<Modal bind:modalOpen>
<div class="admin-modal-content">
<h2>Login</h2>
<p>This login is reserved for site administrators. Go back!</p>
{#if wrong_credentials}
<p style="color: var(--red-one)">Username or password do not match. Try again.</p>
{/if}
<form on:submit|preventDefault={handle_login} bind:this={loginForm}>
<div>
<Input placeholder="Username" required />
<Input
placeholder="Password"
type="password"
onkeydown={(event) => event.key === 'Enter' && loginForm.requestSubmit()}
required
/>
</div>
</form>
</div>
<svelte:fragment slot="buttons">
<Button type="text" on:click={() => (modalOpen = !modalOpen)}>Cancel</Button>
<!-- first paragraph of https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit -->
<Button type="filled" on:click={() => loginForm.requestSubmit()}>Login</Button>
</svelte:fragment>
</Modal>
<style lang="scss">
.admin-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
position: relative;
h2 {
color: var(--primary);
}
form {
display: flex;
gap: 1rem;
div:has(input) {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
width: 100%;
}
div:has(svg) {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
}
}
</style>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { fromNow } from '$util/fromNow';
import { admin_login, passed_login_with_creds } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Dialogue.svelte';
</script>
<Modal bind:modalOpen={$passed_login_with_creds}>
<svelte:fragment slot="title">Successfully logged in!</svelte:fragment>
<div class="login-success">
This session will expire in
<span class="exp-date">{$admin_login.logged_in ? fromNow($admin_login.expires) : '...'}</span>
</div>
<svelte:fragment slot="buttons">
<Button type="filled" on:click={() => passed_login_with_creds.set(false)}>OK</Button>
</svelte:fragment>
</Modal>
<style>
.login-success {
color: var(--text-one);
}
.exp-date {
color: var(--primary);
}
</style>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { set_access_token } from '$lib/auth';
import { admin_login } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Dialogue.svelte';
export let loginOpen: boolean;
$: session_expired = $admin_login.logged_in_previously && !$admin_login.logged_in;
function reset_session() {
set_access_token();
session_expired = !session_expired;
}
</script>
<Modal modalOpen={session_expired}>
<svelte:fragment slot="title">Expired session</svelte:fragment>
<div class="session-expired">
This session has expired, log in again to renew or lose all access to administrative power.
</div>
<svelte:fragment slot="buttons">
<Button type="text" on:click={reset_session}>OK</Button>
<Button type="filled" on:click={() => (reset_session(), (loginOpen = !loginOpen))}>
Login
</Button>
</svelte:fragment>
</Modal>
<style>
.session-expired {
color: var(--text-four);
}
</style>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { fromNow } from '$util/fromNow';
import { admin_login } from '$lib/stores';
import { api_base_url, set_api_base_url, default_api_url } from '$data/api/settings';
import { useQueryClient } from '@tanstack/svelte-query';
import Button from '$lib/components/Button.svelte';
import Modal from '$lib/components/Dialogue.svelte';
import Replay from 'svelte-material-icons/Replay.svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
export let loginOpen: boolean;
export let modalOpen: boolean;
const client = useQueryClient();
let url = api_base_url();
function reload() {
location.reload();
}
function clear_and_reload() {
client.clear();
// `client.clear()` does technically do this for us, but it takes a while.
localStorage.clear();
reload();
}
function save() {
set_api_base_url(url);
reload();
}
function reset() {
url = default_api_url;
}
</script>
<Modal bind:modalOpen>
<svelte:fragment slot="icon">
<Cog size="24px" color="var(--surface-six)" />
</svelte:fragment>
<svelte:fragment slot="title">Settings</svelte:fragment>
<div id="settings-content">
<p>Configure the API for this website.</p>
<div class="input-wrapper">
<input name="api-url" id="api-url" type="text" bind:value={url} />
<button id="button-reset" on:click={reset} aria-label="Reset Button">
<Replay size="24px" color="var(--surface-six)" />
</button>
</div>
</div>
<svelte:fragment slot="buttons">
<div class="buttons-container">
<Button
type="text"
disabled={$admin_login.logged_in}
on:click={() => ((loginOpen = !loginOpen), (modalOpen = !modalOpen))}
>
{$admin_login.logged_in ? `Logged in for ${fromNow($admin_login.expires)}` : 'Login'}
</Button>
<div class="buttons">
<Button type="text" on:click={clear_and_reload} label="Reset Button">Reset</Button>
<Button type="text" on:click={save} label="Save Button">Save</Button>
</div>
</div>
</svelte:fragment>
</Modal>
<style lang="scss">
input {
width: 100%;
position: relative;
padding-right: 3rem;
margin-top: 1rem;
}
#button-reset {
position: absolute;
right: 12px;
top: 30px;
}
button {
background-color: transparent;
border: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
.input-wrapper {
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
position: relative;
}
.buttons-container {
width: 100%;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
div {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 2rem;
}
}
</style>

View File

@@ -14,12 +14,12 @@
if (queryKey !== null) { if (queryKey !== null) {
if (Array.isArray(queryKey)) { if (Array.isArray(queryKey)) {
queryKey.forEach((key) => { queryKey.forEach((key) => {
const query = queries[key]; const query = (queries[key] as Function)();
dev_log('Prefetching', query); dev_log('Prefetching', query);
client.prefetchQuery(query as any); client.prefetchQuery(query as any);
}); });
} else { } else {
const query = queries[queryKey]; const query = (queries[queryKey] as Function)();
dev_log('Prefetching', query); dev_log('Prefetching', query);
client.prefetchQuery(query as any); client.prefetchQuery(query as any);
} }
@@ -27,20 +27,40 @@
} }
</script> </script>
<li class:selected={href === '/' + $RouterEvents.target_url.pathname.split('/')[1]}> <li
class:selected={href === '/' + $RouterEvents.target_url.pathname.split('/')[1]}
class:unclickable={$RouterEvents.target_url.pathname === href}
>
<a data-sveltekit-preload-data on:mouseenter={prefetch} {href} aria-label={label}> <a data-sveltekit-preload-data on:mouseenter={prefetch} {href} aria-label={label}>
<!-- Check if href is equal to the first path -->
<span><slot /></span> <span><slot /></span>
</a> </a>
</li> </li>
<style> <style lang="scss">
li { li {
list-style: none; list-style: none;
position: relative; position: relative;
transition-timing-function: var(--bezier-one); transition-timing-function: var(--bezier-one);
transition-duration: 0.25s; transition-duration: 0.25s;
border-radius: 10px; border-radius: 10px;
&.selected {
background-color: var(--tertiary);
color: var(--primary);
span {
color: var(--primary);
}
}
&.unclickable {
pointer-events: none;
}
:hover {
color: var(--text-one);
background-color: var(--surface-three);
}
} }
a { a {
@@ -60,25 +80,7 @@
color: var(--text-four); color: var(--text-four);
} }
li:hover {
color: var(--text-one);
background-color: var(--surface-three);
}
li.selected {
background-color: var(--tertiary);
color: var(--primary);
pointer-events: none;
}
li.selected span {
color: var(--primary);
}
@media (max-width: 767px) { @media (max-width: 767px) {
li {
border-radius: 100px;
}
a { a {
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
justify-content: left; justify-content: left;

View File

@@ -6,77 +6,51 @@
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import Navigation from './NavButton.svelte'; import Navigation from './NavButton.svelte';
import Modal from '$lib/components/Dialogue.svelte';
import Button from '$lib/components/Button.svelte';
import Banner from '$lib/components/Banner.svelte';
import Query from '$lib/components/Query.svelte'; import Query from '$lib/components/Query.svelte';
import AnnouncementBanner from '../../routes/announcements/AnnouncementBanner.svelte';
import Cog from 'svelte-material-icons/Cog.svelte'; import Cog from 'svelte-material-icons/Cog.svelte';
import Replay from 'svelte-material-icons/Replay.svelte';
import { status_url, api_base_url, set_api_base_url, default_api_url } from '$data/api/settings'; import { status_url } from '$data/api/settings';
import RouterEvents from '$data/RouterEvents'; import RouterEvents from '$data/RouterEvents';
import { queries } from '$data/api'; import { queries } from '$data/api';
import { useQueryClient } from '@tanstack/svelte-query'; import StatusBanner from './StatusBanner.svelte';
import SettingsModal from './Modals/SettingsModal.svelte';
import LoginModal from './Modals/LoginModal.svelte';
import LoginSuccessfulModal from './Modals/LoginSuccessfulModal.svelte';
import SessionExpiredModal from './Modals/SessionExpiredModal.svelte';
const client = useQueryClient(); const ping = createQuery(queries.ping());
function reload() {
location.reload();
}
function clear_and_reload() {
client.clear();
// `client.clear()` does technically do this for us, but it takes a while.
localStorage.clear();
reload();
}
let url = api_base_url();
const statusUrl = status_url(); const statusUrl = status_url();
function save() {
set_api_base_url(url);
reload();
}
function reset() {
url = default_api_url;
}
let menuOpen = false; let menuOpen = false;
let modalOpen = false; const modals: Record<string, boolean> = {
let y: number; settings: false,
const pingQuery = () => createQuery(['ping'], queries.ping); login: false
};
let scrollY: number;
onMount(() => { onMount(() => {
return RouterEvents.subscribe((event) => { return RouterEvents.subscribe((event) => {
if (event.navigating) { if (event.navigating) menuOpen = false;
menuOpen = false;
}
}); });
}); });
</script> </script>
<svelte:window bind:scrollY={y} /> <svelte:window bind:scrollY />
<div id="nav-container"> <span class="banner" class:hide={menuOpen}>
<Query query={pingQuery()} let:data> <Query query={ping} let:data>
{#if !data} {#if !data}
<span class="banner"> <StatusBanner {statusUrl} />
<Banner level="caution" permanent>
The API is currently unresponsive and some services may not work correctly. {#if statusUrl}
Check the <a href={statusUrl} target="_blank" rel="noopener noreferrer">status page</a> for
updates.
{/if}
</Banner>
</span>
{/if} {/if}
</Query> </Query>
<AnnouncementBanner />
</span>
<nav class:scrolled={y > 10}> <nav class:scrolled={scrollY > 10}>
<a class="menu-btn skiptab-btn" href="#skiptab">Skip navigation</a> <a class="menu-btn skiptab-btn" href="#skiptab">Skip navigation</a>
<button <button
@@ -95,28 +69,19 @@
class:desktop-only={!menuOpen} class:desktop-only={!menuOpen}
transition:horizontalSlide={{ direction: 'inline', easing: expoOut, duration: 400 }} transition:horizontalSlide={{ direction: 'inline', easing: expoOut, duration: 400 }}
> >
<div id="banner-pad">
<Query query={pingQuery()} let:data>
{#if !data}
<span class="banner">
<Banner level="caution" permanent>
The API is currently unresponsive and some services may not work correctly. {#if statusUrl}
Check the
<a href={statusUrl} target="_blank" rel="noopener noreferrer">status page</a> for
updates.
{/if}
</Banner>
</span>
{/if}
</Query>
</div>
<div class="nav-wrapper"> <div class="nav-wrapper">
<div id="main-navigation"> <div id="main-navigation">
<ul class="nav-buttons"> <ul class="nav-buttons">
<Navigation href="/" label="Home">Home</Navigation> <Navigation href="/" label="Home">Home</Navigation>
<Navigation queryKey="manager" href="/download" label="Download">Download</Navigation> <Navigation queryKey="manager" href="/download" label="Download">Download</Navigation>
<Navigation queryKey="patches" href="/patches" label="Patches">Patches</Navigation> <Navigation queryKey="patches" href="/patches" label="Patches">Patches</Navigation>
<Navigation
queryKey={['announcements', 'announcementTags']}
href="/announcements"
label="Announcements"
>
Announcements
</Navigation>
<Navigation queryKey="contributors" href="/contributors" label="Contributors"> <Navigation queryKey="contributors" href="/contributors" label="Contributors">
Contributors Contributors
</Navigation> </Navigation>
@@ -126,8 +91,12 @@
</ul> </ul>
</div> </div>
<div id="secondary-navigation"> <div id="secondary-navigation">
<button on:click={() => (modalOpen = !modalOpen)} aria-label="Settings"> <button
<Cog size="20px" color="var(--surface-six)" /> on:click={() => (modals.settings = !modals.settings)}
class:selected={modals.settings}
aria-label="Settings"
>
<Cog size="20px" color={modals.settings ? 'var(--primary)' : 'var(--surface-six)'} />
</button> </button>
</div> </div>
</div> </div>
@@ -135,6 +104,7 @@
{/key} {/key}
{#if menuOpen} {#if menuOpen}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="overlay mobile-only" class="overlay mobile-only"
transition:fade={{ duration: 350 }} transition:fade={{ duration: 350 }}
@@ -142,32 +112,34 @@
on:keypress={() => (menuOpen = !menuOpen)} on:keypress={() => (menuOpen = !menuOpen)}
/> />
{/if} {/if}
</nav> </nav>
</div>
<!-- settings --> <SettingsModal bind:modalOpen={modals.settings} bind:loginOpen={modals.login} />
<Modal bind:modalOpen>
<svelte:fragment slot="icon">
<Cog size="24px" color="var(--surface-six)" />
</svelte:fragment>
<svelte:fragment slot="title">Settings</svelte:fragment>
<svelte:fragment slot="description">Configure the API for this website.</svelte:fragment>
<div id="settings-content">
<div class="input-wrapper">
<input name="api-url" type="text" bind:value={url} />
<button id="button-reset" on:click={reset} aria-label="Reset Button">
<Replay size="24px" color="var(--surface-six)" />
</button>
</div>
</div>
<svelte:fragment slot="buttons"> <LoginModal bind:modalOpen={modals.login} />
<Button type="text" on:click={clear_and_reload} label="Reset Button">Reset</Button>
<Button type="text" on:click={save} label="Save Button">Save</Button> <LoginSuccessfulModal />
</svelte:fragment>
</Modal> <SessionExpiredModal bind:loginOpen={modals.login} />
<style lang="scss"> <style lang="scss">
#secondary-navigation {
display: flex;
button {
border-radius: 10px;
padding: 10px 16px;
&:hover {
background-color: var(--surface-three);
}
&.selected {
background-color: var(--tertiary);
}
}
}
#logo { #logo {
padding: 0.5rem; padding: 0.5rem;
} }
@@ -181,49 +153,21 @@
align-items: center; align-items: center;
} }
.input-wrapper {
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
position: relative;
}
input {
width: 100%;
position: relative;
padding-right: 3rem;
margin-top: 1rem;
}
#button-reset {
position: absolute;
right: 12px;
top: 30px;
}
#nav-container {
z-index: 5;
top: 0;
position: sticky;
width: 100%;
}
nav { nav {
display: flex; position: sticky;
gap: 2rem; top: 0;
justify-content: space-between; z-index: 5;
align-items: center;
padding: 1rem 2rem;
height: 70px;
background-color: var(--surface-eight);
width: 100%;
}
#main-navigation,
#secondary-navigation {
align-items: center;
display: flex; display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem; gap: 2rem;
height: 70px;
padding: 1rem 2rem;
width: 100%;
background-color: var(--surface-eight);
} }
img { img {
@@ -261,7 +205,7 @@
} }
} }
#banner-pad { .banner.hide {
display: none; display: none;
} }
@@ -270,16 +214,6 @@
} }
@media (max-width: 767px) { @media (max-width: 767px) {
#banner-pad {
display: block;
width: 100vw;
visibility: hidden;
}
#nav-container:has(.nav-buttons > li:first-child.selected):has(.banner) {
margin-bottom: 0rem;
}
#nav-wrapper-container { #nav-wrapper-container {
overflow: hidden; overflow: hidden;
position: fixed; position: fixed;
@@ -338,44 +272,52 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
}
.menu-btn__burger { &__burger {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
}
.menu-btn__burger, &,
.menu-btn__burger::before, &::before,
.menu-btn__burger::after { &::after {
width: 24px; width: 24px;
height: 2px; height: 2px;
background: var(--surface-six); background: var(--surface-six);
transition: all 0.3s var(--bezier-one); transition: all 0.3s var(--bezier-one);
} }
.menu-btn__burger::before, &::before,
.menu-btn__burger::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
} }
.menu-btn__burger::before {
&::before {
transform: translateY(-6.5px); transform: translateY(-6.5px);
} }
.menu-btn__burger::after {
&::after {
transform: translateY(6.5px); transform: translateY(6.5px);
} }
}
/* ANIMATION */ /* ANIMATION */
.menu-btn.open .menu-btn__burger { &.open {
.menu-btn__burger {
transform: translateX(-10px); transform: translateX(-10px);
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
}
.menu-btn.open .menu-btn__burger::before { &::before {
transform: rotate(45deg) translate(10px, -10px); transform: rotate(45deg) translate(10px, -10px);
} }
.menu-btn.open .menu-btn__burger::after {
&::after {
transform: rotate(-45deg) translate(10px, 10px); transform: rotate(-45deg) translate(10px, 10px);
} }
}
}
}
.skiptab-btn { .skiptab-btn {
position: fixed; position: fixed;
@@ -388,9 +330,9 @@
font-weight: 600; font-weight: 600;
font-size: 0.95rem; font-size: 0.95rem;
padding: 16px 24px; padding: 16px 24px;
}
.skiptab-btn:focus { &:focus {
left: 12px; left: 12px;
} }
}
</style> </style>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Banner from '$lib/components/Banner.svelte';
export let statusUrl: string | null = null;
const handleClick = () => statusUrl && goto(statusUrl);
</script>
<Banner
title="API service is currently down"
description="We're actively investigating and will update you shortly. We appreciate your patience."
buttonText={statusUrl ? 'View status' : undefined}
buttonOnClick={statusUrl ? handleClick : undefined}
level="caution"
permanent
/>

134
src/lib/auth.ts Normal file
View File

@@ -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<Response> {
// 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<string> {
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<string, string>, 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;
}

View File

@@ -1,160 +1,105 @@
<script lang="ts"> <script lang="ts">
import Info from 'svelte-material-icons/InformationOutline.svelte';
import Warning from 'svelte-material-icons/AlertOutline.svelte';
import Caution from 'svelte-material-icons/AlertCircleOutline.svelte';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Close from 'svelte-material-icons/Close.svelte';
import ArrowRight from 'svelte-material-icons/ArrowRight.svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
export let level: 'info' | 'warning' | 'caution' = 'info'; export let title: string;
export let description: string | undefined = undefined;
export let buttonText: string | undefined = undefined;
export let buttonOnClick: any | undefined = undefined;
export let level: 'info' | 'caution' = 'info';
export let permanent: boolean = false; export let permanent: boolean = false;
export let onDismiss: () => void = () => {};
const icons = { info: Info, warning: Warning, caution: Caution };
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let closed: boolean = false; let closed: boolean = false;
const dismissBanner = () => { function getVariant(level: string): 'default' | 'onDangerBackground' {
return level === 'caution' ? 'onDangerBackground' : 'default';
}
const dismiss = () => {
if (onDismiss) onDismiss();
closed = true; closed = true;
dispatch('dismissed'); dispatch('dismissed');
}; };
</script> </script>
<div class="banner-container" class:closed class:permanent> {#if !closed}
<div class="banner {level}"> <div class="banner {level}" class:permanent>
<div class="banner-text"> <div class="text">
<svelte:component this={icons[level]} size={permanent ? 22.4 : 32} /> <h1 id="title">{title}</h1>
<span><slot /></span> <h2 id="description">{description}</h2>
</div> </div>
<div class="actions">
{#if !permanent} {#if !permanent}
<Button type="text" icon="close" on:click={dismissBanner}>Dismiss</Button> <Button type={'icon'} icon={Close} on:click={dismiss} />
{/if}
{#if buttonText && buttonOnClick}
<Button variant={getVariant(level)} on:click={buttonOnClick}>
{buttonText}
<ArrowRight />
</Button>
{/if} {/if}
</div> </div>
</div> </div>
{/if}
<style> <style lang="scss">
.banner-container, #title {
.banner-container *, line-height: 26px;
.banner-container :global(*) { color: currentColor;
transition: none; font-size: 20px;
} }
.banner-text :global(a) { #description {
color: inherit; line-height: 20px;
text-decoration: none; color: currentColor;
font-weight: 700; font-size: 14px;
}
.banner-text :global(a:hover) {
text-decoration: underline;
}
.banner-container {
display: flex;
justify-content: center;
width: 100%;
}
.banner-container:not(.permanent) {
animation: dropDown var(--bezier-one) 0.7s forwards;
}
.banner-container.closed {
animation: swipeUp var(--bezier-one) 1.5s forwards;
}
.banner-container.permanent {
font-size: 0.87rem;
} }
.banner { .banner {
margin: 0;
padding: 1.5rem 1.7rem;
box-sizing: border-box;
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
box-sizing: border-box;
gap: 1.3rem; gap: 1.3rem;
margin: 0.7rem 1rem;
border-radius: 1rem;
}
.banner-container.permanent > .banner {
padding: 0.5rem 0.7rem;
margin: 0;
border-radius: 0;
width: 100%; width: 100%;
margin: 0;
padding: 24px 40px;
border-radius: 0;
font-size: 0.87rem;
&.info {
background-color: var(--surface-four);
color: var(--text-one);
#description {
color: var(--text-four);
}
}
&.caution {
background-color: var(--red-three);
color: #601410;
} }
.banner-text { @media (max-width: 767px) {
flex-direction: column;
padding: 1.1rem 1.3rem;
}
.text {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
flex: 1; flex: 1;
gap: 0.55rem; gap: 0.55rem;
word-wrap: break-word; word-wrap: break-word;
} }
.banner.info { .actions {
background-color: var(--surface-four); display: flex;
color: var(--text-one); justify-content: end;
} gap: 1rem;
.banner.warning {
background-color: var(--yellow-one);
color: #000;
}
.banner.warning > :global(button) {
color: #000;
}
.banner.warning > :global(button img) {
filter: grayscale(1) brightness(0); /* Make the icon black */
}
.banner.caution {
background-color: var(--red-two);
color: #000;
}
.banner.caution > :global(button) {
color: #000;
}
.banner.caution > :global(button img) {
filter: grayscale(1) brightness(0); /* Make the icon white */
}
.banner > :global(button):hover {
text-decoration: underline;
}
@media screen and (max-width: 767px) {
.banner {
flex-direction: column;
padding: 1.1rem 1.3rem;
}
.banner > :global(button) {
align-self: flex-end;
}
}
@keyframes dropDown {
0% {
top: -100%;
}
100% {
top: 0;
}
}
@keyframes swipeUp {
0% {
top: 0;
}
100% {
top: -100%;
} }
} }
</style> </style>

View File

@@ -1,33 +1,38 @@
<script lang="ts"> <script lang="ts">
export let type: 'filled' | 'tonal' | 'text' | 'outlined'; export let type: 'filled' | 'tonal' | 'text' | 'outlined' | 'icon' = 'filled';
export let variant: 'default' | 'danger' | 'onDangerBackground' = 'default';
export let functionType: typeof HTMLButtonElement.prototype.type = 'button';
export let icon: any | undefined = undefined; export let icon: any | undefined = undefined;
export let iconSize = 20; export let iconSize = 20;
export let iconColor = 'currentColor'; export let iconColor = 'currentColor';
export let href: string = ''; export let href: string = '';
export let target: string = ''; export let target: string = '';
export let label: string = ''; export let label: string = '';
export let disabled: boolean = false;
$: type = $$slots.default ? type : 'icon';
</script> </script>
{#if href} {#if href}
<a {href} {target} class={`button-${type}`} aria-label={label}> <a {href} {target} aria-label={label} class={`${type} ${variant}`} class:disabled>
<svelte:component this={icon} size={iconSize} color={iconColor} /> <svelte:component this={icon} size={iconSize} color={iconColor} />
<slot /> <slot />
</a> </a>
{:else} {:else}
<button on:click class={`button-${type}`} aria-label={label}> <button
on:click
class={`${type} ${variant}`}
class:disabled
aria-label={label}
type={functionType}
{disabled}
>
<svelte:component this={icon} size={iconSize} color={iconColor} /> <svelte:component this={icon} size={iconSize} color={iconColor} />
<slot /> <slot />
</button> </button>
{/if} {/if}
<style> <style lang="scss">
button {
border: none;
background-color: transparent;
padding: 0;
margin: 0;
}
a, a,
button { button {
min-width: max-content; min-width: max-content;
@@ -46,30 +51,55 @@
transform 0.4s var(--bezier-one), transform 0.4s var(--bezier-one),
filter 0.4s var(--bezier-one); filter 0.4s var(--bezier-one);
user-select: none; user-select: none;
padding: 16px 24px;
&:hover:not(.disabled) {
filter: brightness(85%);
} }
.button-filled { &.disabled {
filter: grayscale(100%);
cursor: not-allowed;
}
&.filled {
background-color: var(--primary); background-color: var(--primary);
color: var(--text-three); color: var(--text-three);
} }
.button-tonal {
&.tonal {
background-color: var(--surface-four); background-color: var(--surface-four);
} }
.button-filled, &.text {
.button-tonal {
padding: 16px 24px;
}
.button-text {
background-color: transparent; background-color: transparent;
color: var(--primary); color: var(--primary);
font-weight: 500; font-weight: 500;
letter-spacing: 0.01rem; padding: 0;
} }
button:hover, &.outlined {
a:hover { border: 2px solid var(--primary);
filter: brightness(85%); background-color: transparent;
}
&.icon {
&:hover {
filter: brightness(75%);
}
background-color: transparent;
color: currentColor;
padding: 0;
}
&.danger {
background-color: var(--red-one);
color: var(--surface-four);
}
&.onDangerBackground {
background-color: #ffd3d3;
color: #601410;
}
} }
</style> </style>

View File

@@ -8,7 +8,7 @@
export let fullscreen = false; export let fullscreen = false;
export let notDismissible = false; export let notDismissible = false;
let element: HTMLDivElement; let element: HTMLDialogElement;
let y = 0; let y = 0;
function parseScroll() { function parseScroll() {
@@ -28,9 +28,8 @@
transition:fade={{ easing: quadInOut, duration: 150 }} transition:fade={{ easing: quadInOut, duration: 150 }}
/> />
<div <dialog
class="modal" class="modal"
role="dialog"
class:fullscreen class:fullscreen
class:scrolled={y > 10} class:scrolled={y > 10}
aria-modal="true" aria-modal="true"
@@ -69,7 +68,7 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </dialog>
{/if} {/if}
<style> <style>
@@ -121,6 +120,7 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border: none;
border-radius: 26px; border-radius: 26px;
background-color: var(--surface-seven); background-color: var(--surface-seven);
display: flex; display: flex;

View File

@@ -0,0 +1,19 @@
<svg aria-hidden="true" width="100%" height="8" fill="none" xmlns="http://www.w3.org/2000/svg">
<pattern id="a" width="91" height="8" patternUnits="userSpaceOnUse">
<path
d="M114 4c-5.067 4.667-10.133 4.667-15.2 0S88.667-.667 83.6 4 73.467 8.667 68.4 4 58.267-.667 53.2 4 43.067 8.667 38 4 27.867-.667 22.8 4 12.667 8.667 7.6 4-2.533-.667-7.6 4s-10.133 4.667-15.2 0S-32.933-.667-38 4s-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0"
stroke-linecap="square"
/>
</pattern>
<rect width="100%" height="100%" fill="url(#a)" />
</svg>
<style lang="scss">
svg {
margin: 1.5rem 0;
path {
stroke: var(--border);
}
}
</style>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import ImageModal from '$lib/components/ImageModal.svelte';
export let images: string[];
export let columns: number = 3;
export let gap: string = '1rem';
let selectedImage: { src: string; alt: string } | null = null;
function openModal(image: string, index: number) {
selectedImage = {
src: image,
alt: `Gallery image ${index + 1}`
};
}
function closeModal() {
selectedImage = null;
}
</script>
<div class="gallery" style="--columns: {columns}; --gap: {gap}">
{#each images as image, i}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="image-container">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<img
src={image}
alt={`Gallery image ${i + 1}`}
loading="lazy"
on:click={() => openModal(image, i)}
on:keydown={(e) => e.key === 'Enter' && openModal(image, i)}
tabindex="0"
/>
</div>
{/each}
</div>
{#if selectedImage}
<ImageModal src={selectedImage.src} alt={selectedImage.alt} on:close={closeModal} />
{/if}
<style>
.gallery {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: var(--gap);
width: 100%;
padding: 1rem;
}
.image-container {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
cursor: pointer;
}
img:hover {
transform: scale(1.05);
}
@media (max-width: 768px) {
.gallery {
--columns: 2;
}
}
@media (max-width: 480px) {
.gallery {
--columns: 1;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
export let src: string;
export let alt: string;
const dispatch = createEventDispatcher();
function closeModal() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') closeModal();
}
</script>
<svelte:window on:keydown={handleKeydown} />
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-overlay" on:click={closeModal} transition:fade={{ duration: 175 }}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="modal-content" on:click|stopPropagation transition:fade={{ duration: 175 }}>
<button class="close-button" on:click={closeModal}>×</button>
<img {src} {alt} />
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 2rem;
box-sizing: border-box;
}
.modal-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
}
.close-button {
position: absolute;
top: -1rem;
right: -2rem;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
padding: 0.5rem;
line-height: 1;
z-index: 1001;
}
.close-button:hover {
color: #ddd;
}
</style>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
export let placeholder: string;
export let required: boolean = false;
export let value: any = '';
export let type: string = 'text';
export let onenter: () => void = () => {};
export let onexit: () => void = () => {};
export let oninput: () => void = () => {};
export let onkeydown: (event: KeyboardEvent) => void = (event) => {};
const set_type = (node: HTMLInputElement) => {
node.type = type;
};
</script>
<div class="input-wrapper">
<input
id={placeholder.toLowerCase()}
name={placeholder.toLowerCase()}
{required}
use:set_type
on:focus={onenter}
on:blur={onexit}
on:input={oninput}
on:keydown={onkeydown}
bind:value
/>
<label for={placeholder.toLowerCase()}>{placeholder}</label>
</div>
<style lang="scss">
.input-wrapper {
width: auto;
height: auto;
position: relative;
label {
position: absolute;
top: 29%;
left: 1rem;
transition: all 0.2s ease-in-out;
color: var(--surface-six);
pointer-events: none;
padding: 0;
margin: 0;
font-size: 1rem;
}
input {
font-size: 1rem;
width: 100%;
height: 100%;
&:focus + label,
&:valid + label {
top: -0.65rem;
font-size: 0.85rem;
background-color: var(--surface-seven);
color: var(--text-one);
padding: 0 0.3rem;
}
}
}
</style>

View File

@@ -22,7 +22,7 @@
height: 40vh; height: 40vh;
} }
@media screen and (max-height: 820px) { @media screen and (max-height: 780px) {
svg { svg {
opacity: 0 !important; opacity: 0 !important;
} }

66
src/lib/stores.ts Normal file
View File

@@ -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<AdminLoginInfo>(admin_login_info(), (set) => {
const checkLoginStatus = () => set(admin_login_info());
checkLoginStatus();
const interval = setInterval(checkLoginStatus, 100);
return () => clearInterval(interval);
});
export const read_announcements = writable<Set<number>>(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

View File

@@ -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<ResponseAnnouncement, 'id'>;
export type Tags = { name: string }[];
export interface Contributor { export interface Contributor {
name: string; name: string;
avatar_url: string; avatar_url: string;

View File

@@ -25,6 +25,7 @@
import { events as themeEvents } from '$util/themeEvents'; import { events as themeEvents } from '$util/themeEvents';
import { RV_GOOGLE_TAG_MANAGER_ID } from '$env/static/public'; import { RV_GOOGLE_TAG_MANAGER_ID } from '$env/static/public';
import FooterHost from '$layout/Footer/FooterHost.svelte';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -108,10 +109,7 @@
</noscript> </noscript>
{/if} {/if}
<QueryClientProvider client={queryClient}> <Dialogue bind:modalOpen={showConsentModal} notDismissible>
<NavHost />
<Dialogue bind:modalOpen={showConsentModal} notDismissible>
<svelte:fragment slot="title">It's your choice</svelte:fragment> <svelte:fragment slot="title">It's your choice</svelte:fragment>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
We use analytics to improve your experience on this site. By clicking "Allow", you allow us to We use analytics to improve your experience on this site. By clicking "Allow", you allow us to
@@ -121,8 +119,9 @@
<Button type="text" on:click={() => rememberChoice(false)}>Deny</Button> <Button type="text" on:click={() => rememberChoice(false)}>Deny</Button>
<Button type="filled" on:click={() => rememberChoice(true)}>Allow</Button> <Button type="filled" on:click={() => rememberChoice(true)}>Allow</Button>
</svelte:fragment> </svelte:fragment>
</Dialogue> </Dialogue>
<QueryClientProvider client={queryClient}>
<NavHost />
<div id="skiptab"> <div id="skiptab">
{#if $show_loading_animation} {#if $show_loading_animation}
<Spinner /> <Spinner />
@@ -130,5 +129,5 @@
<slot /> <slot />
{/if} {/if}
</div> </div>
<!-- <Footer> --> <FooterHost />
</QueryClientProvider> </QueryClientProvider>

View File

@@ -1,7 +1,6 @@
<script> <script>
import HeroImage from '$layout/Hero/HeroImage.svelte'; import HeroImage from '$layout/Hero/HeroImage.svelte';
import Home from '$layout/Hero/HeroSection.svelte'; import Home from '$layout/Hero/HeroSection.svelte';
import Footer from '$layout/Footer/FooterHost.svelte';
import Head from '$lib/components/Head.svelte'; import Head from '$lib/components/Head.svelte';
import Wave from '$lib/components/Wave.svelte'; import Wave from '$lib/components/Wave.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -145,19 +144,19 @@
</div> </div>
</main> </main>
<Wave visibility={bottomVisibility} /> <Wave visibility={bottomVisibility} />
<Footer />
<style lang="scss"> <style lang="scss">
.content { .content {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-evenly; justify-content: space-evenly;
width: min(87%, 100rem); width: min(87%, 80rem);
gap: 1rem;
} }
main { main {
overflow: hidden; overflow: hidden;
padding: 5rem 0; padding: 5rem 0;
height: max(100vh, 600px); min-height: max(100vh, 600px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -172,4 +171,10 @@
display: none; display: none;
} }
} }
@media screen and (max-width: 335px) {
main {
padding: 2rem 0 !important;
}
}
</style> </style>

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { derived, readable, type Readable } from 'svelte/store';
import { building } from '$app/environment';
import { page } from '$app/stores';
import { fly, slide } from 'svelte/transition';
import { quintIn, quintOut } from 'svelte/easing';
import Query from '$lib/components/Query.svelte';
import AnnouncementCard from './AnnouncementCard.svelte';
import { queries } from '$data/api';
import TagsHost from './TagsHost.svelte';
import Search from '$lib/components/Search.svelte';
import { onMount } from 'svelte';
import type { ResponseAnnouncement } from '$lib/types';
import { admin_login } from '$lib/stores';
import Button from '$lib/components/Button.svelte';
import moment from 'moment';
import { debounce } from '$util/debounce';
import createFilter from '$util/filter';
import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
import Create from 'svelte-material-icons/Plus.svelte';
let searchParams: Readable<URLSearchParams>;
if (building) searchParams = readable(new URLSearchParams());
else searchParams = derived(page, ($page) => $page.url.searchParams);
let searchTerm = $searchParams.get('s') || '';
$: query = createQuery(queries.announcements());
$: tagsQuery = createQuery(queries.announcementTags());
$: selectedTags = $searchParams.getAll('tag');
let expanded = false;
function filterAnnouncements(
announcements: Iterable<ResponseAnnouncement>,
search: string,
selectedTags: string[]
): ResponseAnnouncement[] {
const announcementFilter = createFilter(Array.from(announcements), {
searcherOptions: {
keys: ['title', 'content']
},
additionalFilter: (announcement: ResponseAnnouncement, tags: string[]): boolean => {
return (
tags.length === 0 ||
tags.some((tag) => announcement.tags && announcement.tags.includes(tag))
);
}
});
return announcementFilter(selectedTags, search);
}
// Make sure we don't have to filter the announcements after every key press
let displayedTerm = '';
const update = () => {
displayedTerm = searchTerm;
const url = new URL(window.location.href);
url.pathname = '/announcements';
if (searchTerm) url.searchParams.set('s', searchTerm);
else url.searchParams.delete('s');
};
onMount(update);
</script>
<div class="search">
<div class="search-contain">
<!-- Must bind both variables: we get searchTerm from the text input, -->
<div class="search-bar">
<Search
bind:searchTerm
bind:displayedTerm
title="Search for announcements"
on:keyup={debounce(update)}
/>
</div>
{#if $admin_login.logged_in}
<Button type="filled" icon={Create} href="/announcements/create">Create</Button>
{/if}
</div>
</div>
<main class="wrapper" in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
<Query query={tagsQuery} let:data>
<TagsHost tags={data.tags} />
</Query>
<Query {query} let:data>
<div class="cards">
{#each filterAnnouncements(data.announcements, displayedTerm, selectedTags) as announcement}
{#if !announcement.archived_at || moment(announcement.archived_at).isAfter(moment())}
{#key selectedTags || displayedTerm}
<div in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
<AnnouncementCard {announcement} />
</div>
{/key}
{/if}
{/each}
</div>
<div
role="button"
class="expand-archived"
aria-expanded={expanded}
class:closed={!expanded}
on:click={() => (expanded = !expanded)}
on:keypress={() => (expanded = !expanded)}
tabindex="0"
>
<h4>Archived announcements</h4>
<div id="arrow" style:transform={expanded ? 'rotate(0deg)' : 'rotate(-180deg)'}>
<ChevronDown size="24px" color="var(--surface-six)" />
</div>
</div>
{#if expanded}
<div
class="cards"
in:slide={{ easing: quintIn, duration: 250 }}
out:slide={{ easing: quintOut, duration: 250 }}
>
{#each filterAnnouncements(data.announcements, displayedTerm, selectedTags) as announcement}
{#if announcement.archived_at && moment(announcement.archived_at).isBefore(moment())}
{#key selectedTags || displayedTerm}
<AnnouncementCard {announcement} />
{/key}
{/if}
{/each}
</div>
{/if}
</Query>
</main>
<style lang="scss">
.expand-archived {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
padding: 0rem 0.25rem;
#arrow {
height: 1.5rem;
transition: all 0.2s var(--bezier-one);
}
}
.search {
padding-top: 0.6rem;
padding-bottom: 1.25rem;
background-color: var(--surface-eight);
.search-contain {
display: flex;
justify-content: center;
gap: 1rem;
margin-inline: auto;
width: min(90%, 80rem);
.search-bar {
flex: 1;
}
}
}
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
padding: 16px 0;
min-width: 0;
width: 100%;
gap: 16px;
}
@media (max-width: 767px) {
.cards {
display: flex;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { read_announcements } from '$lib/stores';
import Banner from '$lib/components/Banner.svelte';
import { goto } from '$app/navigation';
import type { ResponseAnnouncement } from '$lib/types';
import { browser } from '$app/environment';
import { createQuery } from '@tanstack/svelte-query';
import { queries } from '$data/api';
import moment from 'moment';
let latestUnreadAnnouncement: ResponseAnnouncement | undefined = undefined;
const query = createQuery(queries.announcements());
$: {
if ($query.data?.announcements && $query.data.announcements.length > 0) {
const nonArchived = $query.data.announcements.filter(
(a) => !a.archived_at || moment(a.archived_at).isAfter(moment())
);
const announcement = nonArchived[0];
if (announcement && !$read_announcements.has(announcement.id)) {
latestUnreadAnnouncement = announcement;
} else {
latestUnreadAnnouncement = undefined;
}
}
}
function setAsRead() {
if (!latestUnreadAnnouncement) return;
$read_announcements.add(latestUnreadAnnouncement.id);
localStorage.setItem('read_announcements', JSON.stringify(Array.from($read_announcements)));
latestUnreadAnnouncement = undefined;
}
function handleClick() {
if (latestUnreadAnnouncement) {
goto(`/announcements/${latestUnreadAnnouncement.id}`);
setAsRead();
}
}
function handleClose() {
if (latestUnreadAnnouncement && browser) {
setAsRead();
}
}
function getBannerLevel(level: number | undefined): 'info' | 'caution' {
if (!level || level == 0) return 'info';
return 'caution';
}
</script>
{#if latestUnreadAnnouncement}
<Banner
title={'We have an announcement'}
description={`You can read more about "${latestUnreadAnnouncement.title}" in our latest post.`}
level={getBannerLevel(latestUnreadAnnouncement.level)}
buttonText="Read more"
buttonOnClick={handleClick}
onDismiss={handleClose}
/>
{/if}

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import moment from 'moment';
import { onMount } from 'svelte';
import type { ResponseAnnouncement } from '$lib/types';
import NewHeader from './NewHeader.svelte';
import { queries } from '$data/api';
import { dev_log } from '$util/dev';
import { useQueryClient } from '@tanstack/svelte-query';
import { read_announcements } from '$lib/stores';
import TagsHost from './TagsHost.svelte';
import Content from './[slug]/Content.svelte';
import ToolTip from '$lib/components/ToolTip.svelte';
import { relativeTime } from '$util/relativeTime';
import Archive from 'svelte-material-icons/ArchiveOutline.svelte';
export let announcement: ResponseAnnouncement;
const client = useQueryClient();
let isRead: boolean;
function prefetch() {
const query = queries['announcementById'](announcement.id);
dev_log('Prefetching', query);
client.prefetchQuery(query);
}
function setAnnouncementRead() {
isRead = true;
$read_announcements.add(announcement.id);
localStorage.setItem('read_announcements', JSON.stringify(Array.from($read_announcements)));
}
function generateSlug(title: string) {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
onMount(() => {
isRead = $read_announcements.has(announcement.id);
});
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<a
data-sveltekit-preload-data
on:mouseenter={prefetch}
href={`/announcements/${announcement.id}-${generateSlug(announcement.title)}`}
on:click={setAnnouncementRead}
>
<div
class="card"
class:attachment={announcement.attachments && announcement.attachments.length > 0}
>
{#if isRead !== undefined && !isRead}
<NewHeader />
{/if}
{#if announcement.attachments && announcement.attachments.length > 0}
<img
src={announcement.attachments[0]}
class={isRead === undefined || isRead ? '' : 'no-border-radius'}
alt="Banner"
onerror="this.style.display='none'"
/>
{/if}
<div class="content">
<div class="header">
<h3>{announcement.title}</h3>
<span>
{relativeTime(announcement.created_at)}
{#if announcement.archived_at && moment(announcement.archived_at).isBefore(moment())}
<ToolTip
content={`This announcement was archived ${relativeTime(announcement.archived_at)}`}
>
<Archive size="24" />
</ToolTip>
{/if}
</span>
</div>
<div class="footer">
{#if announcement.content}
<Content content={announcement.content} clamp={true} />
{/if}
{#if announcement.tags && announcement.tags.length > 0}
<hr />
<TagsHost
tags={announcement.tags.map((tag) => ({ name: tag }))}
expandable={false}
clickable={false}
/>
{/if}
</div>
</div>
</div>
</a>
<style lang="scss">
a {
text-decoration: inherit;
}
.card {
&.attachment {
grid-row: span 2;
}
&:hover {
background-color: var(--surface-four);
filter: none;
}
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--surface-seven);
border: 1px solid var(--border);
border-radius: 12px;
img {
height: 150px;
object-fit: cover;
width: 100%;
border-radius: 12px 12px 0px 0px;
&.no-border-radius {
border-radius: 0;
}
}
.content {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
height: 100%;
padding: 12px 16px;
color: var(--text-four);
.header,
.footer {
display: flex;
flex-direction: column;
overflow-wrap: anywhere;
span {
display: flex;
gap: 4px;
img {
height: 24px;
width: 24px;
}
}
}
.footer {
gap: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,14 @@
<span>NEW</span>
<style>
span {
text-align: center;
background-color: var(--surface-four);
color: var(--primary);
font-weight: bold;
padding: 4px 0;
border-radius: 12px 12px 0 0;
pointer-events: none;
letter-spacing: 0.05em;
}
</style>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import Check from 'svelte-material-icons/Check.svelte';
export let tag: string;
export let clickable: boolean = true;
export let selected: boolean = false;
export let onClick: (event?: MouseEvent) => void = () => {};
selected = clickable && selected;
</script>
<button class:selected class:clickable on:click={clickable ? onClick : () => {}}>
{#if selected && clickable}
<div class="icon">
<Check />
</div>
{/if}
{tag}
</button>
<style lang="scss">
button {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
border-radius: 8px;
border: none;
background-color: var(--tertiary);
color: var(--text-four);
letter-spacing: 0.02rem;
font-size: 0.85rem;
user-select: none;
transition: all 0.2s var(--bezier-one);
&.clickable {
background-color: transparent;
border: 1px solid var(--border);
&.selected {
border-color: transparent;
background-color: var(--tertiary);
color: var(--primary);
.icon {
display: inherit;
margin-left: -6px;
transition: none;
}
}
&:hover {
background-color: var(--surface-three);
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import TagChip from './TagChip.svelte';
import { derived } from 'svelte/store';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { Tags } from '$lib/types';
import Button from '$lib/components/Button.svelte';
import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
export let tags: Tags;
export let expandable: boolean = false;
export let clickable: boolean = true;
let showAllTags = expandable ? false : true;
const searchParams = derived(page, ($page) => $page.url.searchParams);
$: selectedTags = $searchParams.getAll('tag');
$: displayedTags = (() => {
if (showAllTags) return tags.map((tag) => tag.name);
if (selectedTags.length > 0) {
return [tags[0]?.name, ...selectedTags.filter((tag) => tag !== tags[0]?.name)];
}
return tags.length > 0 ? [tags[0]?.name] : [];
})();
const handleClick = (tag: string) => {
const url = new URL(window.location.href);
const params = new URLSearchParams(url.search);
if (params.getAll('tag').includes(tag)) params.delete('tag', tag);
else params.append('tag', tag);
url.search = params.toString();
goto(url.pathname + url.search);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
</script>
<div>
{#each displayedTags as tag}
<TagChip
{tag}
selected={$searchParams.getAll('tag').includes(tag)}
onClick={() => handleClick(tag)}
{clickable}
/>
{/each}
{#if expandable && tags.length > 1}
<li class="button">
<Button type="text" on:click={() => (showAllTags = !showAllTags)}>
<div
class="expand-arrow"
style:transform={showAllTags ? 'rotate(90deg)' : 'rotate(-90deg)'}
>
<ChevronDown size="24px" color="var(--surface-six)" />
</div>
</Button>
</li>
{/if}
</div>
<style lang="scss">
div {
display: flex;
align-items: center;
gap: 4px;
.button {
display: flex;
align-items: center;
}
.expand-arrow {
transition: all 0.2s var(--bezier-one);
user-select: none;
height: 1.5rem;
}
.rotate .expand-arrow {
transform: rotate(180deg);
}
}
</style>

View File

@@ -0,0 +1 @@
export const prerender = false;

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import Footer from '$layout/Footer/FooterHost.svelte';
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { createQuery } from '@tanstack/svelte-query';
import { queries } from '$data/api';
import { page } from '$app/stores';
import Announcement from './Announcement.svelte';
import Query from '$lib/components/Query.svelte';
let announcementIdNumber: number | undefined = undefined;
let isCreating: boolean = false;
$: {
const lastSegment = $page.url.pathname.split('/').pop();
isCreating = lastSegment === 'create';
announcementIdNumber = isCreating ? undefined : Number(lastSegment.split('-')[0]);
}
$: query = announcementIdNumber
? createQuery(queries.announcementById(announcementIdNumber))
: null;
$: announcement = $query?.data?.announcement || undefined;
$: slug = announcement?.title
? announcement.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
: '';
$: {
const slugPathname = `/announcements/${announcementIdNumber}-${slug}`;
if (slug && $page.url.pathname !== slugPathname) {
window.history.replaceState(null, '', slugPathname);
}
}
</script>
<main class="wrapper" in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
{#if query}
<Query {query}>
<Announcement {isCreating} {announcement} {announcementIdNumber} {query} />
</Query>
{:else}
<Announcement {isCreating} {announcement} {announcementIdNumber} />
{/if}
</main>
<Footer />

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { useQueryClient, type CreateQueryResult } from '@tanstack/svelte-query';
import { admin, queries } from '$data/api';
import { goto } from '$app/navigation';
import Button from '$lib/components/Button.svelte';
import Dialogue from '$lib/components/Dialogue.svelte';
import type { Announcement, ResponseAnnouncement } from '$lib/types';
import moment from 'moment';
import { isValidUrl } from '$util/isValidUrl';
import Delete from 'svelte-material-icons/DeleteOutline.svelte';
import Edit from 'svelte-material-icons/PencilOutline.svelte';
import Archive from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import Check from 'svelte-material-icons/Check.svelte';
import Show from 'svelte-material-icons/EyeOutline.svelte';
import Hide from 'svelte-material-icons/EyeOffOutline.svelte';
import Unarchive from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
export let isEditing: boolean;
export let isCreating: boolean;
export let isPreviewing: boolean;
export let archivedAtInput: string | undefined;
export let showDeleteConfirm: boolean;
export let announcementIdNumber: number | undefined;
export let draftInputs: Announcement;
export let query: CreateQueryResult<{ announcement: ResponseAnnouncement }, unknown> | undefined;
const client = useQueryClient();
const toggleArchived = () => {
if (archivedAtInput) archivedAtInput = undefined;
else archivedAtInput = moment().format('YYYY-MM-DDTHH:mm');
};
const isValid = () => {
const hasEmptyTitle = !draftInputs.title;
const hasEmptyAttachments = draftInputs.attachments?.some((a) => !isValidUrl(a));
if (hasEmptyTitle || hasEmptyAttachments) {
alert(
`${[hasEmptyTitle && 'Title', hasEmptyAttachments && 'Attachments']
.filter(Boolean)
.join(' and ')} must be filled properly`
);
return false;
}
return true;
};
const sanitize = (draftInputs: Announcement) => {
return {
...draftInputs,
content: draftInputs.content?.trim() || undefined,
tags: draftInputs.tags && draftInputs.tags.length > 0 ? draftInputs.tags : undefined,
archived_at: draftInputs.archived_at?.trim() || undefined,
attachments:
draftInputs.attachments && draftInputs.attachments?.length > 0
? draftInputs.attachments
: undefined,
author: draftInputs.author?.trim() || undefined,
level: draftInputs.level ?? undefined
};
};
const save = async () => {
if (!isValid()) return;
await admin.update_announcement(announcementIdNumber!, sanitize(draftInputs));
await $query?.refetch();
isEditing = false;
};
const createAnnouncement = async () => {
if (!isValid()) return;
await admin.create_announcement(sanitize(draftInputs));
await client.invalidateQueries(queries['announcements']());
goto('/announcements', { invalidateAll: true });
};
const deleteAnnouncement = async () => {
admin.delete_announcement(announcementIdNumber!);
await client.invalidateQueries(queries['announcements']());
goto('/announcements', { invalidateAll: true });
};
const handleUnload = (e: BeforeUnloadEvent) => {
if (isEditing) {
e.preventDefault();
e.returnValue = '';
}
};
</script>
<svelte:window on:beforeunload={handleUnload} />
<Dialogue bind:modalOpen={showDeleteConfirm}>
<svelte:fragment slot="title">Confirm?</svelte:fragment>
<svelte:fragment slot="description">Do you want to delete this announcement?</svelte:fragment>
<svelte:fragment slot="buttons">
<Button type="text" on:click={() => (showDeleteConfirm = !showDeleteConfirm)}>Cancel</Button>
<Button type="filled" on:click={deleteAnnouncement}>OK</Button>
</svelte:fragment>
</Dialogue>
<div>
{#if isEditing || isCreating}
<Button
icon={isPreviewing ? Hide : Show}
iconColor="var(--secondary)"
on:click={() => (isPreviewing = !isPreviewing)}
/>
<Button
icon={archivedAtInput ? Unarchive : Archive}
iconColor="var(--secondary)"
on:click={toggleArchived}
/>
{#if isEditing}
<Button
icon={Close}
iconColor="var(--secondary)"
on:click={() => {
isPreviewing = false;
isEditing = false;
}}
/>
{/if}
<Button
icon={Check}
iconColor="var(--secondary)"
on:click={isEditing ? save : createAnnouncement}
/>
{:else}
<Button
icon={Delete}
iconColor="var(--secondary)"
on:click={() => (showDeleteConfirm = !showDeleteConfirm)}
/>
<Button icon={Edit} iconColor="var(--secondary)" on:click={() => (isEditing = !isEditing)} />
{/if}
</div>
<style>
div {
display: flex;
gap: 1rem;
color: var(--secondary);
}
</style>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { admin_login } from '$lib/stores';
import Title from './Title.svelte';
import Divider from '$lib/components/Divider.svelte';
import AdminButtons from './AdminButtons.svelte';
import Author from './Author.svelte';
import Date from './Date.svelte';
import Content from './Content.svelte';
import Attachments from './Attachments.svelte';
import Tags from './Tags.svelte';
import type { Announcement, ResponseAnnouncement } from '$lib/types';
import type { CreateQueryResult } from '@tanstack/svelte-query';
export let isCreating: boolean;
export let announcement: Announcement | undefined;
export let announcementIdNumber: number | undefined;
export let query: CreateQueryResult<{ announcement: ResponseAnnouncement }, unknown> | undefined =
undefined;
let isPreviewing = false;
let isEditing = false;
let showDeleteConfirm = false;
const draftInputs: Announcement = {
...announcement,
id: undefined
};
</script>
<div class="card">
<div class="header">
<div>
<Title
{isCreating}
{isEditing}
{isPreviewing}
title={announcement?.title}
bind:titleInput={draftInputs.title}
/>
<h4>
<Date
{isCreating}
{isEditing}
{isPreviewing}
createdAt={announcement?.created_at}
archivedAt={announcement?.archived_at}
bind:archivedAtInput={draftInputs.archived_at}
bind:createdAtInput={draftInputs.created_at}
/>
<Author
{isCreating}
{isEditing}
{isPreviewing}
author={announcement?.author}
bind:authorInput={draftInputs.author}
/>
</h4>
<Tags {isCreating} {isEditing} {isPreviewing} bind:tagsInput={draftInputs.tags} />
</div>
{#if $admin_login.logged_in}
<AdminButtons
{isCreating}
bind:isEditing
bind:isPreviewing
bind:showDeleteConfirm
bind:archivedAtInput={draftInputs.archived_at}
{draftInputs}
{announcementIdNumber}
{query}
/>
{/if}
</div>
<Divider />
<Content
{isCreating}
{isEditing}
{isPreviewing}
content={announcement?.content}
bind:contentInput={draftInputs.content}
/>
<Attachments
{isCreating}
{isEditing}
{isPreviewing}
attachments={announcement?.attachments}
bind:attachmentsInput={draftInputs.attachments}
/>
</div>
<style lang="scss">
.card {
background-color: var(--surface-eight);
display: flex;
flex-direction: column;
padding: 2rem;
margin-bottom: 3rem;
border-radius: 1rem;
}
.header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 2rem;
}
@media (max-width: 767px) {
.card {
background-color: initial;
padding: 0;
border-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import Divider from '$lib/components/Divider.svelte';
import Gallery from '$lib/components/Gallery.svelte';
import { isValidUrl } from '$util/isValidUrl';
import Create from 'svelte-material-icons/Plus.svelte';
import Delete from 'svelte-material-icons/DeleteOutline.svelte';
export let isEditing: boolean;
export let isCreating: boolean;
export let isPreviewing: boolean;
export let attachments: string[] | undefined;
export let attachmentsInput: string[] | undefined;
let newAttachment: string | null = null;
const isValidAnnouncement = (attachment: string | null) => {
return attachment && attachment && isValidUrl(attachment);
};
const addAttachment = (attachment: string | null) => {
if (!isValidAnnouncement(attachment)) return;
attachmentsInput = [...(attachmentsInput ?? []), attachment ? attachment : ''];
return true;
};
const removeAttachment = (index: number) => {
if (!attachmentsInput) return;
attachmentsInput = attachmentsInput.filter((_, i) => i !== index);
};
$: displayAttachments = isPreviewing ? attachmentsInput : attachments;
</script>
{#if (isEditing || isCreating) && !isPreviewing}
<Divider />
<div class="attachments-wrapper">
{#if attachmentsInput}
{#each attachmentsInput as attachment, index}
<div class="attachments">
<input
bind:value={attachmentsInput[index]}
class:empty={!attachment || (attachment && !isValidUrl(attachment))}
placeholder="Attachment URL"
/>
<button
class:last={index == attachmentsInput.length - 1}
on:click={() => removeAttachment(index)}
>
<Delete size="24" color="var(--text-four)" />
</button>
</div>
{/each}
{/if}
<span id="new-attachment">
<input
bind:value={newAttachment}
class:empty={!isValidAnnouncement(newAttachment)}
on:blur={() => {
addAttachment(newAttachment);
newAttachment = null;
}}
on:keydown={(event) => {
if (event.key === 'Enter' && addAttachment(newAttachment)) newAttachment = null;
}}
/>
<span>
<Button icon={Create} />
</span>
</span>
</div>
{:else if displayAttachments && displayAttachments?.length > 0}
<Divider />
<Gallery images={displayAttachments} />
{/if}
<style lang="scss">
button {
display: flex;
justify-content: center;
background-color: transparent;
border: none;
cursor: pointer;
}
input {
width: 100%;
padding-right: 40px;
letter-spacing: 0.02rem;
font-size: 0.85rem;
transition: all 0.2s var(--bezier-one);
&:focus {
outline: none;
border: 1px solid var(--primary);
}
&.empty {
border: 1px solid var(--red-one);
}
}
#new-attachment {
display: inline-flex;
align-items: center;
position: relative;
input {
width: 52px;
border: 1px solid var(--border);
padding-right: 0;
&:focus {
width: 100%;
+ span {
display: none;
}
&.empty {
border: 1px solid var(--red-one);
}
}
}
span {
pointer-events: none;
position: absolute;
left: 15.5px;
}
}
.attachments-wrapper {
display: flex;
flex-direction: column;
width: 100%;
gap: 1rem;
.attachments {
display: flex;
justify-content: center;
align-items: center;
position: relative;
gap: 1rem;
button {
position: absolute;
right: 10px;
top: 14px;
}
}
}
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
export let isEditing: boolean;
export let isCreating: boolean;
export let isPreviewing: boolean;
export let author: string | undefined;
export let authorInput: string | undefined;
$: displayAuthor = isPreviewing ? authorInput : author;
</script>
{#if (isEditing || isCreating) && !isPreviewing}
·
<input
bind:value={authorInput}
class:empty={!authorInput?.trim()}
placeholder="Enter author name"
/>
{:else if displayAuthor}
·
<span>
{displayAuthor}
</span>
{/if}
<style lang="scss">
input {
&,
&:focus {
border: none;
outline: none;
border-radius: 0;
}
padding: 0;
font-size: 1rem;
letter-spacing: 0.02rem;
}
</style>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
export let isEditing: boolean = false;
export let isCreating: boolean = false;
export let isPreviewing: boolean = false;
export let content: string | undefined;
export let contentInput: string | undefined = undefined;
export let clamp: boolean = false;
$: displayContent = isPreviewing ? contentInput : content;
</script>
{#if (isEditing || isCreating) && !isPreviewing}
<textarea bind:value={contentInput} class:empty={!content?.trim()} placeholder="Enter content" />
{:else if displayContent}
<div class:clamp>
{@html displayContent}
</div>
{/if}
<style lang="scss">
textarea {
&,
&:focus {
border: none;
outline: none;
border-radius: 0;
}
padding: 0;
font-size: 1rem;
letter-spacing: 0.02rem;
}
div {
color: var(--text-four);
&.clamp {
display: -webkit-inline-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
:global(a) {
pointer-events: none;
}
:global(h1),
:global(h2),
:global(h3),
:global(h4),
:global(h5),
:global(h6) {
color: var(--secondary);
line-height: 1.75rem;
margin: 0;
}
}
:global(a) {
color: var(--primary);
font-weight: 600;
font-size: 0.95rem;
text-decoration: none;
&:hover {
text-decoration: underline var(--secondary);
color: var(--text-one);
}
}
:global(h2),
:global(h3),
:global(h4),
:global(h5),
:global(h6) {
color: var(--secondary);
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
:global(h1) {
font-size: 1.8rem;
}
:global(h2) {
font-size: 1.6rem;
}
:global(h3) {
font-size: 1.4rem;
}
:global(h4) {
font-size: 1.2rem;
}
:global(h5) {
font-size: 1.1rem;
}
:global(h6) {
font-size: 1rem;
}
:global(li) {
list-style-position: inside;
font-size: 0.9rem;
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { relativeTime } from '$util/relativeTime';
import moment from 'moment';
import ArrowRight from 'svelte-material-icons/ArrowRight.svelte';
export let isEditing: boolean;
export let isCreating: boolean;
export let isPreviewing: boolean;
export let createdAt: string | undefined;
export let createdAtInput: string | undefined;
export let archivedAt: string | undefined;
export let archivedAtInput: string | undefined;
if (createdAtInput) {
createdAtInput = moment(createdAtInput).format('YYYY-MM-DDTHH:mm');
} else {
createdAtInput = moment().format('YYYY-MM-DDTHH:mm');
}
$: displayCreatedAt = isPreviewing ? createdAtInput : createdAt;
$: displayArchivedAt = isPreviewing ? archivedAtInput : archivedAt;
</script>
{#if (isEditing || isCreating) && !isPreviewing}
<span>
<input type="datetime-local" max="9999-12-31T23:59" bind:value={createdAtInput} />
{#if archivedAtInput}
<ArrowRight size="24" />
<input type="datetime-local" max="9999-12-31T23:59" bind:value={archivedAtInput} />
{/if}
</span>
{:else if displayCreatedAt}
<span>
{relativeTime(displayCreatedAt)}
{#if displayArchivedAt}
<ArrowRight size="24" />
{relativeTime(displayArchivedAt)}
{/if}
</span>
{/if}
<style lang="scss">
span {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
column-gap: 1rem;
}
input {
&,
&:focus {
border: none;
outline: none;
border-radius: 0;
}
&::-webkit-calendar-picker-indicator {
filter: invert(88%) sepia(60%) saturate(4731%) hue-rotate(173deg) brightness(91%)
contrast(111%);
}
padding: 0;
font-size: 1rem;
letter-spacing: 0.02rem;
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import Button from '$lib/components/Button.svelte';
import TagChip from '../TagChip.svelte';
import { createQuery } from '@tanstack/svelte-query';
import { queries } from '$data/api';
import Query from '$lib/components/Query.svelte';
import Create from 'svelte-material-icons/Plus.svelte';
export let isEditing: boolean;
export let isCreating: boolean;
export let isPreviewing: boolean;
export let tagsInput: string[];
$: query = createQuery(queries.announcementTags());
$: tags = $query.data?.tags || [];
let newTag: string | null;
function handleTag(tag: string | null) {
if (!tag) return;
if (tags.some((t) => t.name === tag)) {
if (tagsInput?.includes(tag)) {
tagsInput = tagsInput.filter((t) => t !== tag);
if (!$query.data?.tags.some((t) => t.name === tag)) {
tags = tags.filter((t) => t.name !== tag);
}
} else {
tagsInput = [...(tagsInput || []), tag];
}
} else {
tags = [...tags, { name: tag }];
tagsInput = [...(tagsInput || []), tag];
}
newTag = null;
}
</script>
{#if (isEditing || isCreating) && !isPreviewing}
<Query {query}>
<div>
{#each tags as tag}
<TagChip
tag={tag.name}
selected={tagsInput && tagsInput.includes(tag.name)}
onClick={() => handleTag(tag.name)}
/>
{/each}
<div id="new-tag">
<input
bind:value={newTag}
class:empty={!newTag}
on:blur={() => handleTag(newTag)}
on:keydown={(event) => {
if (event.key === 'Enter') handleTag(newTag);
}}
/>
<span>
<Button icon={Create} iconColor="var(--text-four)" />
</span>
</div>
</div>
</Query>
{/if}
<style lang="scss">
div {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
#new-tag {
display: inline-flex;
position: relative;
input {
&,
&:focus {
border: none;
outline: none;
}
&:focus {
width: 100%;
+ span {
display: none;
}
}
width: 38px;
}
span {
pointer-events: none;
position: absolute;
left: 9px;
top: 6px;
}
}
input {
height: 32px;
border-radius: 8px;
background-color: var(--tertiary);
color: var(--text-four);
letter-spacing: 0.02rem;
font-size: 0.85rem;
transition: all 0.2s var(--bezier-one);
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
export let isEditing: boolean;
export let isCreating: boolean;
export let isPreviewing: boolean;
export let title: string | undefined;
export let titleInput: string;
$: displayTitle = isPreviewing ? titleInput : title;
</script>
{#if (isEditing || isCreating) && !isPreviewing}
<input bind:value={titleInput} class:empty={!titleInput?.trim()} placeholder="Enter title" />
{:else if displayTitle}
<h1>
{displayTitle}
</h1>
{/if}
<style lang="scss">
h1 {
font-size: 2.5rem;
}
input {
&,
&:focus {
border: none;
outline: none;
border-radius: 0;
}
width: 100%;
padding: 0;
color: var(--text-one);
font-size: 2.5rem;
font-weight: 700;
line-height: 4rem;
letter-spacing: -0.025em;
}
</style>

View File

@@ -3,14 +3,13 @@
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import ContributorHost from './ContributorSection.svelte'; import ContributorHost from './ContributorSection.svelte';
import Footer from '$layout/Footer/FooterHost.svelte';
import Head from '$lib/components/Head.svelte'; import Head from '$lib/components/Head.svelte';
import Query from '$lib/components/Query.svelte'; import Query from '$lib/components/Query.svelte';
import { queries } from '$data/api'; import { queries } from '$data/api';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
const query = createQuery(['contributors'], queries.contributors); const query = createQuery(queries.contributors());
</script> </script>
<Head <Head
@@ -62,8 +61,6 @@
</div> </div>
</main> </main>
<Footer />
<style> <style>
.repos { .repos {
display: flex; display: flex;

View File

@@ -6,7 +6,6 @@
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import Head from '$lib/components/Head.svelte'; import Head from '$lib/components/Head.svelte';
import Footer from '$layout/Footer/FooterHost.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Snackbar from '$lib/components/Snackbar.svelte'; import Snackbar from '$lib/components/Snackbar.svelte';
import Query from '$lib/components/Query.svelte'; import Query from '$lib/components/Query.svelte';
@@ -22,8 +21,8 @@
import { supportsWebP } from '$util/supportsWebP'; import { supportsWebP } from '$util/supportsWebP';
const teamQuery = createQuery(['team'], queries.team); const teamQuery = createQuery(queries.team());
const aboutQuery = createQuery(['about'], queries.about); const aboutQuery = createQuery(queries.about());
let qrCodeDialogue = false; let qrCodeDialogue = false;
let cryptoDialogue = false; let cryptoDialogue = false;
@@ -205,8 +204,6 @@
<svelte:fragment slot="text">Address copied to clipboard</svelte:fragment> <svelte:fragment slot="text">Address copied to clipboard</svelte:fragment>
</Snackbar> </Snackbar>
<Footer />
<style lang="scss"> <style lang="scss">
main { main {
display: flex; display: flex;

View File

@@ -12,12 +12,11 @@
import Head from '$lib/components/Head.svelte'; import Head from '$lib/components/Head.svelte';
import Query from '$lib/components/Query.svelte'; import Query from '$lib/components/Query.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Footer from '$layout/Footer/FooterHost.svelte';
import Picture from '$lib/components/Picture.svelte'; import Picture from '$lib/components/Picture.svelte';
import Dialogue from '$lib/components/Dialogue.svelte'; import Dialogue from '$lib/components/Dialogue.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
const query = createQuery(['manager'], queries.manager); const query = createQuery(queries.manager());
let warning: string; let warning: string;
let warningDialogue = false; let warningDialogue = false;
@@ -85,7 +84,7 @@
</svelte:fragment> </svelte:fragment>
</Dialogue> </Dialogue>
<div class="wrapper center" in:fly={{ y: 10, easing: quintOut, duration: 750 }}> <main class="wrapper center" in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
<h2>ReVanced <span>Manager</span></h2> <h2>ReVanced <span>Manager</span></h2>
<p>Patch your favourite apps, right on your device.</p> <p>Patch your favourite apps, right on your device.</p>
<div class="buttons"> <div class="buttons">
@@ -112,9 +111,7 @@
<div class="screenshot"> <div class="screenshot">
<Picture data={manager_screenshot} alt="Manager Screenshot" /> <Picture data={manager_screenshot} alt="Manager Screenshot" />
</div> </div>
</div> </main>
<Footer />
<style> <style>
.center { .center {

View File

@@ -5,7 +5,7 @@
import { derived, readable, type Readable } from 'svelte/store'; import { derived, readable, type Readable } from 'svelte/store';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Patch } from '$lib/types'; import type { CompatiblePackage, Patch } from '$lib/types';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { queries } from '$data/api'; import { queries } from '$data/api';
@@ -14,15 +14,16 @@
import PackageMenu from './PackageMenu.svelte'; import PackageMenu from './PackageMenu.svelte';
import Package from './Package.svelte'; import Package from './Package.svelte';
import PatchItem from './PatchItem.svelte'; import PatchItem from './PatchItem.svelte';
import Footer from '$layout/Footer/FooterHost.svelte';
import Search from '$lib/components/Search.svelte'; import Search from '$lib/components/Search.svelte';
import FilterChip from '$lib/components/FilterChip.svelte'; import FilterChip from '$lib/components/FilterChip.svelte';
import Dialogue from '$lib/components/Dialogue.svelte'; import Dialogue from '$lib/components/Dialogue.svelte';
import Query from '$lib/components/Query.svelte'; import Query from '$lib/components/Query.svelte';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import createFilter from '$util/filter';
import { debounce } from '$util/debounce';
const query = createQuery(['patches'], queries.patches); const query = createQuery(queries.patches());
let searcher: Fuse<Patch> | undefined; let searcher: Fuse<Patch> | undefined;
@@ -39,49 +40,26 @@
let mobilePackages = false; let mobilePackages = false;
let showAllVersions = false; let showAllVersions = false;
function checkCompatibility(patch: Patch, pkg: string) { function filterPatches(patches: Patch[], pkg: string, search?: string): Patch[] {
if (pkg === '') { const patchFilter = createFilter(patches, {
return false; searcherOptions: {
keys: ['name', 'description', 'compatiblePackages.name', 'compatiblePackages.versions']
},
additionalFilter: (patch: Patch, pkg: string): boolean => {
return (
patch.compatiblePackages?.some(
(compatiblePackage: CompatiblePackage) =>
compatiblePackage.name === pkg || compatiblePackage.versions?.includes(pkg)
) || false
);
} }
return !!patch.compatiblePackages?.find((compat) => compat.name === pkg);
}
function filter(patches: Patch[], pkg: string, search?: string): Patch[] {
if (!search) {
if (pkg) return patches.filter((patch) => checkCompatibility(patch, pkg));
else return patches;
}
if (!searcher) {
searcher = new Fuse(patches, {
keys: ['name', 'description', 'compatiblePackages.name', 'compatiblePackages.versions'],
shouldSort: true,
threshold: 0.3
}); });
}
const result = searcher return patchFilter(pkg, search);
.search(search)
.map(({ item }) => item)
.filter((item) => {
// Don't show if the patch doesn't support the selected package
if (pkg && !checkCompatibility(item, pkg)) {
return false;
}
return true;
});
return result;
} }
// Make sure we don't have to filter the patches after every key press // Make sure we don't have to filter the patches after every key press
let displayedTerm = ''; let displayedTerm = '';
const debounce = <T extends any[]>(f: (...args: T) => void) => {
let timeout: number;
return (...args: T) => {
clearTimeout(timeout);
timeout = setTimeout(() => f(...args), 350);
};
};
const update = () => { const update = () => {
displayedTerm = searchTerm; displayedTerm = searchTerm;
@@ -184,7 +162,7 @@
</aside> </aside>
<div class="patches-container"> <div class="patches-container">
{#each filter(data.patches, selectedPkg || '', displayedTerm) as patch} {#each filterPatches(data.patches, selectedPkg || '', displayedTerm) as patch}
<!-- Trigger new animations when package or search changes (I love Svelte) --> <!-- Trigger new animations when package or search changes (I love Svelte) -->
{#key selectedPkg || displayedTerm} {#key selectedPkg || displayedTerm}
<div in:fly={{ y: 10, easing: quintOut, duration: 750 }}> <div in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
@@ -195,7 +173,6 @@
</div> </div>
</Query> </Query>
</main> </main>
<Footer />
<style> <style>
main { main {

7
src/util/debounce.ts Normal file
View File

@@ -0,0 +1,7 @@
export const debounce = <T extends any[]>(f: (...args: T) => void) => {
let timeout: number;
return (...args: T) => {
clearTimeout(timeout);
timeout = setTimeout(() => f(...args), 350);
};
};

34
src/util/filter.ts Normal file
View File

@@ -0,0 +1,34 @@
import Fuse from 'fuse.js';
type SearcherOptions<T> = {
keys: string[];
shouldSort?: boolean;
threshold?: number;
};
type FilterOptions<T, C> = {
searcherOptions: SearcherOptions<T>;
additionalFilter?: (item: T, context: C) => boolean;
};
function createFilter<T, C>(items: T[], options: FilterOptions<T, C>) {
const { searcherOptions, additionalFilter } = options;
const searcher = new Fuse(items, {
keys: searcherOptions.keys,
shouldSort: searcherOptions.shouldSort ?? true,
threshold: searcherOptions.threshold ?? 0.3
});
return (context: C, search?: string): T[] => {
if (!search) {
return additionalFilter ? items.filter((item) => additionalFilter(item, context)) : items;
}
const results = searcher.search(search).map(({ item }) => item);
return additionalFilter ? results.filter((item) => additionalFilter(item, context)) : results;
};
}
export default createFilter;

3
src/util/fromNow.ts Normal file
View File

@@ -0,0 +1,3 @@
import moment from 'moment';
export const fromNow = (timestamp: number) => moment(timestamp).fromNow(true);

8
src/util/isValidUrl.ts Normal file
View File

@@ -0,0 +1,8 @@
export const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch (err) {
return false;
}
};

7
src/util/relativeTime.ts Normal file
View File

@@ -0,0 +1,7 @@
import moment from 'moment';
export const relativeTime = (date: string, withinDays: number = 7) => {
return moment().diff(moment(date), 'days') <= withinDays
? moment(date).fromNow()
: moment(date).format('on MMMM D, YYYY [at] h:mm A');
};