mirror of
https://github.com/ReVanced/revanced-website.git
synced 2026-01-11 05:36:17 +00:00
chore: Merge branch dev to main (#293)
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@tanstack/query-sync-storage-persister": "^4.36.1",
|
||||
"@tanstack/svelte-query": "^4.36.1",
|
||||
"datetrigger": "^1.1.1",
|
||||
"moment": "^2.30.1",
|
||||
"svelte-material-icons": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3429,6 +3430,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@tanstack/query-sync-storage-persister": "^4.36.1",
|
||||
"@tanstack/svelte-query": "^4.36.1",
|
||||
"datetrigger": "^1.1.1",
|
||||
"moment": "^2.30.1",
|
||||
"svelte-material-icons": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
74
pnpm-lock.yaml
generated
74
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
datetrigger:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
moment:
|
||||
specifier: ^2.30.1
|
||||
version: 2.30.1
|
||||
svelte-material-icons:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5(svelte@4.2.18)
|
||||
@@ -1192,6 +1195,9 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1241,8 +1247,8 @@ packages:
|
||||
periscopic@3.1.0:
|
||||
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
|
||||
|
||||
picocolors@1.0.1:
|
||||
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
|
||||
picocolors@1.1.0:
|
||||
resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
@@ -1276,8 +1282,8 @@ packages:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.4.41:
|
||||
resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==}
|
||||
postcss@8.4.47:
|
||||
resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -1343,8 +1349,8 @@ packages:
|
||||
resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
semver@7.6.2:
|
||||
resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==}
|
||||
semver@7.6.3:
|
||||
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1375,8 +1381,8 @@ packages:
|
||||
resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
@@ -2051,7 +2057,7 @@ snapshots:
|
||||
fast-glob: 3.3.2
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
ts-api-utils: 1.3.0(typescript@5.7.2)
|
||||
optionalDependencies:
|
||||
typescript: 5.7.2
|
||||
@@ -2175,7 +2181,7 @@ snapshots:
|
||||
css-tree@2.3.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.30
|
||||
source-map-js: 1.2.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
@@ -2229,7 +2235,7 @@ snapshots:
|
||||
eslint-compat-utils@0.5.1(eslint@9.16.0):
|
||||
dependencies:
|
||||
eslint: 9.16.0
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
|
||||
eslint-config-prettier@9.1.0(eslint@9.16.0):
|
||||
dependencies:
|
||||
@@ -2243,11 +2249,11 @@ snapshots:
|
||||
eslint-compat-utils: 0.5.1(eslint@9.16.0)
|
||||
esutils: 2.0.3
|
||||
known-css-properties: 0.35.0
|
||||
postcss: 8.4.41
|
||||
postcss-load-config: 3.1.4(postcss@8.4.41)
|
||||
postcss-safe-parser: 6.0.0(postcss@8.4.41)
|
||||
postcss: 8.4.47
|
||||
postcss-load-config: 3.1.4(postcss@8.4.47)
|
||||
postcss-safe-parser: 6.0.0(postcss@8.4.47)
|
||||
postcss-selector-parser: 6.1.2
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
svelte-eslint-parser: 0.43.0(svelte@4.2.18)
|
||||
optionalDependencies:
|
||||
svelte: 4.2.18
|
||||
@@ -2500,6 +2506,8 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
moment@2.30.1: {}
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
mrmime@2.0.0: {}
|
||||
@@ -2544,35 +2552,35 @@ snapshots:
|
||||
estree-walker: 3.0.3
|
||||
is-reference: 3.0.2
|
||||
|
||||
picocolors@1.0.1: {}
|
||||
picocolors@1.1.0: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
postcss-load-config@3.1.4(postcss@8.4.41):
|
||||
postcss-load-config@3.1.4(postcss@8.4.47):
|
||||
dependencies:
|
||||
lilconfig: 2.1.0
|
||||
yaml: 1.10.2
|
||||
optionalDependencies:
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
|
||||
postcss-safe-parser@6.0.0(postcss@8.4.41):
|
||||
postcss-safe-parser@6.0.0(postcss@8.4.47):
|
||||
dependencies:
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
|
||||
postcss-scss@4.0.9(postcss@8.4.41):
|
||||
postcss-scss@4.0.9(postcss@8.4.47):
|
||||
dependencies:
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.4.41:
|
||||
postcss@8.4.47:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
picocolors: 1.0.1
|
||||
source-map-js: 1.2.0
|
||||
picocolors: 1.1.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
@@ -2629,7 +2637,7 @@ snapshots:
|
||||
dependencies:
|
||||
chokidar: 4.0.3
|
||||
immutable: 5.0.3
|
||||
source-map-js: 1.2.0
|
||||
source-map-js: 1.2.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher': 2.5.0
|
||||
|
||||
@@ -2639,7 +2647,7 @@ snapshots:
|
||||
|
||||
semiver@1.1.0: {}
|
||||
|
||||
semver@7.6.2: {}
|
||||
semver@7.6.3: {}
|
||||
|
||||
set-cookie-parser@2.7.0: {}
|
||||
|
||||
@@ -2647,7 +2655,7 @@ snapshots:
|
||||
dependencies:
|
||||
color: 4.2.3
|
||||
detect-libc: 2.0.3
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.33.4
|
||||
'@img/sharp-darwin-x64': 0.33.4
|
||||
@@ -2696,7 +2704,7 @@ snapshots:
|
||||
mrmime: 2.0.0
|
||||
totalist: 3.0.1
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
@@ -2711,7 +2719,7 @@ snapshots:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
chokidar: 4.0.3
|
||||
fdir: 6.4.2
|
||||
picocolors: 1.0.1
|
||||
picocolors: 1.1.0
|
||||
sade: 1.8.1
|
||||
svelte: 4.2.18
|
||||
typescript: 5.7.2
|
||||
@@ -2723,8 +2731,8 @@ snapshots:
|
||||
eslint-scope: 7.2.2
|
||||
eslint-visitor-keys: 3.4.3
|
||||
espree: 9.6.1
|
||||
postcss: 8.4.41
|
||||
postcss-scss: 4.0.9(postcss@8.4.41)
|
||||
postcss: 8.4.47
|
||||
postcss-scss: 4.0.9(postcss@8.4.47)
|
||||
optionalDependencies:
|
||||
svelte: 4.2.18
|
||||
|
||||
@@ -2813,7 +2821,7 @@ snapshots:
|
||||
vite@5.3.3(sass@1.81.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
rollup: 4.20.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
41
src/app.html
41
src/app.html
@@ -1,26 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="/logo.png" />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="/logo.png" />
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:image" itemprop="image" content="/logo.png" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:image" itemprop="image" content="/logo.png" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
%sveltekit.body%
|
||||
</body>
|
||||
</html>
|
||||
|
||||
16
src/app.scss
16
src/app.scss
@@ -74,6 +74,7 @@ body {
|
||||
|
||||
--red-one: hsl(333, 84%, 62%);
|
||||
--red-two: hsl(357, 74%, 60%);
|
||||
--red-three: hsl(2, 68%, 83%);
|
||||
|
||||
--yellow-one: hsl(59, 100%, 72%);
|
||||
|
||||
@@ -86,6 +87,10 @@ body {
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
/*-----headings-----*/
|
||||
|
||||
h1 {
|
||||
@@ -173,7 +178,13 @@ hr {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
input {
|
||||
textarea {
|
||||
resize: vertical;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -181,6 +192,7 @@ input {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ import type {
|
||||
DonationPlatform,
|
||||
CryptoWallet,
|
||||
Social,
|
||||
About
|
||||
About,
|
||||
ResponseAnnouncement,
|
||||
Announcement,
|
||||
Tags
|
||||
} from '$lib/types';
|
||||
import { get_access_token, is_logged_in, UnauthenticatedError } from '$lib/auth';
|
||||
|
||||
export type ContributorsData = { contributables: Contributable[] };
|
||||
export type PatchesData = { patches: Patch[]; packages: string[] };
|
||||
@@ -19,10 +23,70 @@ export type TeamData = { members: TeamMember[] };
|
||||
export type AboutData = { about: About };
|
||||
export type DonationData = { wallets: CryptoWallet[]; platforms: DonationPlatform[] };
|
||||
export type SocialsData = { socials: Social[] };
|
||||
export type AnnouncementsData = { announcements: ResponseAnnouncement[] };
|
||||
|
||||
type GetAnnouncementsOptions = Partial<{
|
||||
tags: string[];
|
||||
count: number;
|
||||
cursor: number;
|
||||
}>;
|
||||
|
||||
export function build_url(endpoint: string) {
|
||||
// //////v4/contributors -> v4/contributors
|
||||
endpoint = endpoint.replace(/^\/+/, '');
|
||||
|
||||
// v4/contributors -> contributors
|
||||
if (endpoint.startsWith(settings.API_VERSION)) endpoint = endpoint.split('/').slice(1).join('/');
|
||||
|
||||
return `${settings.api_base_url()}/${settings.API_VERSION}/${endpoint}`;
|
||||
}
|
||||
|
||||
function build_headers() {
|
||||
const access_token_data = get_access_token();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: access_token_data ? `Bearer ${access_token_data.token}` : ''
|
||||
};
|
||||
}
|
||||
|
||||
async function get_json(endpoint: string) {
|
||||
const url = `${settings.api_base_url()}/${endpoint}`;
|
||||
return await fetch(url).then((r) => r.json());
|
||||
return await fetch(build_url(endpoint)).then((r) => r.json());
|
||||
}
|
||||
|
||||
async function post_json(endpoint: string, body?: any) {
|
||||
if (!is_logged_in()) throw new UnauthenticatedError();
|
||||
const headers = build_headers();
|
||||
return await fetch(build_url(endpoint), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : ''
|
||||
}).then((r) => {
|
||||
return r.headers.get('content-length') === '0' ? null : r.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function patch_json(endpoint: string, body?: any) {
|
||||
if (!is_logged_in()) throw new UnauthenticatedError();
|
||||
const headers = build_headers();
|
||||
return await fetch(build_url(endpoint), {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : ''
|
||||
}).then((r) => {
|
||||
return r.headers.get('content-length') === '0' ? null : r.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function delete_json(endpoint: string, body?: any) {
|
||||
if (!is_logged_in()) throw new UnauthenticatedError();
|
||||
const headers = build_headers();
|
||||
return await fetch(build_url(endpoint), {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : ''
|
||||
}).then((r) => {
|
||||
return r.headers.get('content-length') === '0' ? null : r.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function contributors(): Promise<ContributorsData> {
|
||||
@@ -74,6 +138,58 @@ async function about(): Promise<AboutData> {
|
||||
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> {
|
||||
try {
|
||||
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 queries = {
|
||||
manager: {
|
||||
manager: () => ({
|
||||
queryKey: ['manager'],
|
||||
queryFn: manager,
|
||||
staleTime
|
||||
},
|
||||
patches: {
|
||||
}),
|
||||
patches: () => ({
|
||||
queryKey: ['patches'],
|
||||
queryFn: patches,
|
||||
staleTime
|
||||
},
|
||||
contributors: {
|
||||
}),
|
||||
contributors: () => ({
|
||||
queryKey: ['contributors'],
|
||||
queryFn: contributors,
|
||||
staleTime
|
||||
},
|
||||
team: {
|
||||
}),
|
||||
team: () => ({
|
||||
queryKey: ['team'],
|
||||
queryFn: team,
|
||||
staleTime
|
||||
},
|
||||
about: {
|
||||
}),
|
||||
about: () => ({
|
||||
queryKey: ['info'],
|
||||
queryFn: about,
|
||||
staleTime
|
||||
},
|
||||
ping: {
|
||||
}),
|
||||
announcements: () => ({
|
||||
queryKey: ['announcements'],
|
||||
queryFn: () => announcements(),
|
||||
staleTime
|
||||
}),
|
||||
announcementById: (id: number) => ({
|
||||
queryKey: ['announcementById', id],
|
||||
queryFn: () => get_announcement_by_id(id)
|
||||
}),
|
||||
announcementTags: () => ({
|
||||
queryKey: ['announcementTags'],
|
||||
queryFn: () => announcementTags(),
|
||||
staleTime
|
||||
}),
|
||||
ping: () => ({
|
||||
queryKey: ['ping'],
|
||||
queryFn: ping,
|
||||
staleTime
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ function set_status_url(apiUrl: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export const API_VERSION = 'v4';
|
||||
|
||||
// Get base URL
|
||||
export function api_base_url(): string {
|
||||
if (browser) {
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
import FooterSection from './FooterSection.svelte';
|
||||
|
||||
import { RV_DMCA_GUID } from '$env/static/public';
|
||||
import { onMount } from 'svelte';
|
||||
const aboutQuery = createQuery(['about'], queries.about);
|
||||
|
||||
const aboutQuery = createQuery(queries.about());
|
||||
|
||||
let location: string;
|
||||
|
||||
|
||||
@@ -14,16 +14,10 @@
|
||||
}
|
||||
|
||||
.hero-img {
|
||||
height: 70vh;
|
||||
height: max(100vh, 600px);
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-radius: 1.75rem;
|
||||
background-color: var(--surface-seven);
|
||||
user-select: none;
|
||||
}
|
||||
@media screen and (max-width: 1700px) {
|
||||
.hero-img {
|
||||
height: 100vh;
|
||||
right: 6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import SocialButton from './SocialButton.svelte';
|
||||
|
||||
const aboutQuery = createQuery(['about'], queries.about);
|
||||
const aboutQuery = createQuery(queries.about());
|
||||
|
||||
export let socialsVisibility = true;
|
||||
</script>
|
||||
@@ -60,6 +60,7 @@
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
@@ -69,27 +70,9 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1700px) {
|
||||
@media screen and (max-width: 1100px) {
|
||||
.hero {
|
||||
height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 820px) {
|
||||
.social-buttons {
|
||||
bottom: initial;
|
||||
left: initial;
|
||||
position: initial;
|
||||
width: initial;
|
||||
opacity: 100% !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 1100px) or (min-height: 820px) {
|
||||
.social-buttons {
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
justify-content: center;
|
||||
padding-top: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,11 +83,34 @@
|
||||
}
|
||||
|
||||
.social-buttons {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero {
|
||||
height: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) or (min-height: 780px) {
|
||||
.social-buttons {
|
||||
transform: translateX(-50%);
|
||||
width: 90%;
|
||||
position: absolute;
|
||||
left: initial;
|
||||
transform: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-height: 780px) {
|
||||
.social-buttons {
|
||||
transform: initial;
|
||||
left: initial;
|
||||
position: initial;
|
||||
width: initial;
|
||||
opacity: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
86
src/layout/Navbar/Modals/LoginModal.svelte
Normal file
86
src/layout/Navbar/Modals/LoginModal.svelte
Normal 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>
|
||||
28
src/layout/Navbar/Modals/LoginSuccessfulModal.svelte
Normal file
28
src/layout/Navbar/Modals/LoginSuccessfulModal.svelte
Normal 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>
|
||||
35
src/layout/Navbar/Modals/SessionExpiredModal.svelte
Normal file
35
src/layout/Navbar/Modals/SessionExpiredModal.svelte
Normal 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>
|
||||
116
src/layout/Navbar/Modals/SettingsModal.svelte
Normal file
116
src/layout/Navbar/Modals/SettingsModal.svelte
Normal 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>
|
||||
@@ -14,12 +14,12 @@
|
||||
if (queryKey !== null) {
|
||||
if (Array.isArray(queryKey)) {
|
||||
queryKey.forEach((key) => {
|
||||
const query = queries[key];
|
||||
const query = (queries[key] as Function)();
|
||||
dev_log('Prefetching', query);
|
||||
client.prefetchQuery(query as any);
|
||||
});
|
||||
} else {
|
||||
const query = queries[queryKey];
|
||||
const query = (queries[queryKey] as Function)();
|
||||
dev_log('Prefetching', query);
|
||||
client.prefetchQuery(query as any);
|
||||
}
|
||||
@@ -27,20 +27,40 @@
|
||||
}
|
||||
</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}>
|
||||
<!-- Check if href is equal to the first path -->
|
||||
<span><slot /></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
li {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
transition-timing-function: var(--bezier-one);
|
||||
transition-duration: 0.25s;
|
||||
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 {
|
||||
@@ -60,25 +80,7 @@
|
||||
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) {
|
||||
li {
|
||||
border-radius: 100px;
|
||||
}
|
||||
a {
|
||||
padding: 0.75rem 1.25rem;
|
||||
justify-content: left;
|
||||
|
||||
@@ -6,168 +6,140 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
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 AnnouncementBanner from '../../routes/announcements/AnnouncementBanner.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 { 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();
|
||||
|
||||
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 ping = createQuery(queries.ping());
|
||||
const statusUrl = status_url();
|
||||
|
||||
function save() {
|
||||
set_api_base_url(url);
|
||||
reload();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
url = default_api_url;
|
||||
}
|
||||
|
||||
let menuOpen = false;
|
||||
let modalOpen = false;
|
||||
let y: number;
|
||||
const pingQuery = () => createQuery(['ping'], queries.ping);
|
||||
const modals: Record<string, boolean> = {
|
||||
settings: false,
|
||||
login: false
|
||||
};
|
||||
|
||||
let scrollY: number;
|
||||
|
||||
onMount(() => {
|
||||
return RouterEvents.subscribe((event) => {
|
||||
if (event.navigating) {
|
||||
menuOpen = false;
|
||||
}
|
||||
if (event.navigating) menuOpen = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY={y} />
|
||||
<svelte:window bind:scrollY />
|
||||
|
||||
<div id="nav-container">
|
||||
<Query query={pingQuery()} let:data>
|
||||
<span class="banner" class:hide={menuOpen}>
|
||||
<Query query={ping} 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>
|
||||
<StatusBanner {statusUrl} />
|
||||
{/if}
|
||||
</Query>
|
||||
<AnnouncementBanner />
|
||||
</span>
|
||||
|
||||
<nav class:scrolled={y > 10}>
|
||||
<a class="menu-btn skiptab-btn" href="#skiptab">Skip navigation</a>
|
||||
<nav class:scrolled={scrollY > 10}>
|
||||
<a class="menu-btn skiptab-btn" href="#skiptab">Skip navigation</a>
|
||||
|
||||
<button
|
||||
class="menu-btn mobile-only"
|
||||
on:click={() => (menuOpen = !menuOpen)}
|
||||
class:open={menuOpen}
|
||||
aria-label="Menu"
|
||||
<button
|
||||
class="menu-btn mobile-only"
|
||||
on:click={() => (menuOpen = !menuOpen)}
|
||||
class:open={menuOpen}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<span class="menu-btn__burger" />
|
||||
</button>
|
||||
<a href="/" id="logo"><img src="/logo.svg" alt="ReVanced Logo" /></a>
|
||||
|
||||
{#key menuOpen}
|
||||
<div
|
||||
id="nav-wrapper-container"
|
||||
class:desktop-only={!menuOpen}
|
||||
transition:horizontalSlide={{ direction: 'inline', easing: expoOut, duration: 400 }}
|
||||
>
|
||||
<span class="menu-btn__burger" />
|
||||
</button>
|
||||
<a href="/" id="logo"><img src="/logo.svg" alt="ReVanced Logo" /></a>
|
||||
|
||||
{#key menuOpen}
|
||||
<div
|
||||
id="nav-wrapper-container"
|
||||
class:desktop-only={!menuOpen}
|
||||
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 class="nav-wrapper">
|
||||
<div id="main-navigation">
|
||||
<ul class="nav-buttons">
|
||||
<Navigation href="/" label="Home">Home</Navigation>
|
||||
<Navigation queryKey="manager" href="/download" label="Download">Download</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">
|
||||
Contributors
|
||||
</Navigation>
|
||||
<Navigation queryKey={['about', 'team']} href="/donate" label="Donate"
|
||||
>Donate</Navigation
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="nav-wrapper">
|
||||
<div id="main-navigation">
|
||||
<ul class="nav-buttons">
|
||||
<Navigation href="/" label="Home">Home</Navigation>
|
||||
<Navigation queryKey="manager" href="/download" label="Download">Download</Navigation>
|
||||
<Navigation queryKey="patches" href="/patches" label="Patches">Patches</Navigation>
|
||||
<Navigation queryKey="contributors" href="/contributors" label="Contributors">
|
||||
Contributors
|
||||
</Navigation>
|
||||
<Navigation queryKey={['about', 'team']} href="/donate" label="Donate"
|
||||
>Donate</Navigation
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="secondary-navigation">
|
||||
<button on:click={() => (modalOpen = !modalOpen)} aria-label="Settings">
|
||||
<Cog size="20px" color="var(--surface-six)" />
|
||||
<div id="secondary-navigation">
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="overlay mobile-only"
|
||||
transition:fade={{ duration: 350 }}
|
||||
on:click={() => (menuOpen = !menuOpen)}
|
||||
on:keypress={() => (menuOpen = !menuOpen)}
|
||||
/>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- settings -->
|
||||
<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>
|
||||
{/key}
|
||||
|
||||
<svelte:fragment slot="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>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
{#if menuOpen}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="overlay mobile-only"
|
||||
transition:fade={{ duration: 350 }}
|
||||
on:click={() => (menuOpen = !menuOpen)}
|
||||
on:keypress={() => (menuOpen = !menuOpen)}
|
||||
/>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<SettingsModal bind:modalOpen={modals.settings} bind:loginOpen={modals.login} />
|
||||
|
||||
<LoginModal bind:modalOpen={modals.login} />
|
||||
|
||||
<LoginSuccessfulModal />
|
||||
|
||||
<SessionExpiredModal bind:loginOpen={modals.login} />
|
||||
|
||||
<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 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@@ -181,49 +153,21 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
height: 70px;
|
||||
background-color: var(--surface-eight);
|
||||
width: 100%;
|
||||
}
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
#main-navigation,
|
||||
#secondary-navigation {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
|
||||
height: 70px;
|
||||
padding: 1rem 2rem;
|
||||
width: 100%;
|
||||
|
||||
background-color: var(--surface-eight);
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -261,7 +205,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
#banner-pad {
|
||||
.banner.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -270,16 +214,6 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
@@ -338,43 +272,51 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-btn__burger {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-btn__burger,
|
||||
.menu-btn__burger::before,
|
||||
.menu-btn__burger::after {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--surface-six);
|
||||
transition: all 0.3s var(--bezier-one);
|
||||
}
|
||||
&__burger {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.menu-btn__burger::before,
|
||||
.menu-btn__burger::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
.menu-btn__burger::before {
|
||||
transform: translateY(-6.5px);
|
||||
}
|
||||
.menu-btn__burger::after {
|
||||
transform: translateY(6.5px);
|
||||
}
|
||||
/* ANIMATION */
|
||||
.menu-btn.open .menu-btn__burger {
|
||||
transform: translateX(-10px);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.menu-btn.open .menu-btn__burger::before {
|
||||
transform: rotate(45deg) translate(10px, -10px);
|
||||
}
|
||||
.menu-btn.open .menu-btn__burger::after {
|
||||
transform: rotate(-45deg) translate(10px, 10px);
|
||||
&,
|
||||
&::before,
|
||||
&::after {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--surface-six);
|
||||
transition: all 0.3s var(--bezier-one);
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: translateY(-6.5px);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: translateY(6.5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ANIMATION */
|
||||
&.open {
|
||||
.menu-btn__burger {
|
||||
transform: translateX(-10px);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
&::before {
|
||||
transform: rotate(45deg) translate(10px, -10px);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: rotate(-45deg) translate(10px, 10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skiptab-btn {
|
||||
@@ -388,9 +330,9 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.skiptab-btn:focus {
|
||||
left: 12px;
|
||||
&:focus {
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
17
src/layout/Navbar/StatusBanner.svelte
Normal file
17
src/layout/Navbar/StatusBanner.svelte
Normal 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
134
src/lib/auth.ts
Normal 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;
|
||||
}
|
||||
@@ -1,160 +1,105 @@
|
||||
<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 Close from 'svelte-material-icons/Close.svelte';
|
||||
import ArrowRight from 'svelte-material-icons/ArrowRight.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;
|
||||
|
||||
const icons = { info: Info, warning: Warning, caution: Caution };
|
||||
export let onDismiss: () => void = () => {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let closed: boolean = false;
|
||||
|
||||
const dismissBanner = () => {
|
||||
function getVariant(level: string): 'default' | 'onDangerBackground' {
|
||||
return level === 'caution' ? 'onDangerBackground' : 'default';
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (onDismiss) onDismiss();
|
||||
closed = true;
|
||||
dispatch('dismissed');
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="banner-container" class:closed class:permanent>
|
||||
<div class="banner {level}">
|
||||
<div class="banner-text">
|
||||
<svelte:component this={icons[level]} size={permanent ? 22.4 : 32} />
|
||||
<span><slot /></span>
|
||||
{#if !closed}
|
||||
<div class="banner {level}" class:permanent>
|
||||
<div class="text">
|
||||
<h1 id="title">{title}</h1>
|
||||
<h2 id="description">{description}</h2>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if !permanent}
|
||||
<Button type={'icon'} icon={Close} on:click={dismiss} />
|
||||
{/if}
|
||||
{#if buttonText && buttonOnClick}
|
||||
<Button variant={getVariant(level)} on:click={buttonOnClick}>
|
||||
{buttonText}
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !permanent}
|
||||
<Button type="text" icon="close" on:click={dismissBanner}>Dismiss</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.banner-container,
|
||||
.banner-container *,
|
||||
.banner-container :global(*) {
|
||||
transition: none;
|
||||
<style lang="scss">
|
||||
#title {
|
||||
line-height: 26px;
|
||||
color: currentColor;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.banner-text :global(a) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.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;
|
||||
#description {
|
||||
line-height: 20px;
|
||||
color: currentColor;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 1.5rem 1.7rem;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
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%;
|
||||
}
|
||||
margin: 0;
|
||||
padding: 24px 40px;
|
||||
border-radius: 0;
|
||||
font-size: 0.87rem;
|
||||
|
||||
.banner-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 0.55rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
&.info {
|
||||
background-color: var(--surface-four);
|
||||
color: var(--text-one);
|
||||
|
||||
.banner.info {
|
||||
background-color: var(--surface-four);
|
||||
color: var(--text-one);
|
||||
}
|
||||
#description {
|
||||
color: var(--text-four);
|
||||
}
|
||||
}
|
||||
&.caution {
|
||||
background-color: var(--red-three);
|
||||
color: #601410;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
padding: 1.1rem 1.3rem;
|
||||
}
|
||||
|
||||
.banner > :global(button) {
|
||||
align-self: flex-end;
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 0.55rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropDown {
|
||||
0% {
|
||||
top: -100%;
|
||||
}
|
||||
100% {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swipeUp {
|
||||
0% {
|
||||
top: 0;
|
||||
}
|
||||
100% {
|
||||
top: -100%;
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
<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 iconSize = 20;
|
||||
export let iconColor = 'currentColor';
|
||||
export let href: string = '';
|
||||
export let target: string = '';
|
||||
export let label: string = '';
|
||||
export let disabled: boolean = false;
|
||||
|
||||
$: type = $$slots.default ? type : 'icon';
|
||||
</script>
|
||||
|
||||
{#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} />
|
||||
<slot />
|
||||
</a>
|
||||
{: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} />
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
button {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
<style lang="scss">
|
||||
a,
|
||||
button {
|
||||
min-width: max-content;
|
||||
@@ -46,30 +51,55 @@
|
||||
transform 0.4s var(--bezier-one),
|
||||
filter 0.4s var(--bezier-one);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.button-filled {
|
||||
background-color: var(--primary);
|
||||
color: var(--text-three);
|
||||
}
|
||||
.button-tonal {
|
||||
background-color: var(--surface-four);
|
||||
}
|
||||
|
||||
.button-filled,
|
||||
.button-tonal {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
background-color: transparent;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01rem;
|
||||
}
|
||||
&:hover:not(.disabled) {
|
||||
filter: brightness(85%);
|
||||
}
|
||||
|
||||
button:hover,
|
||||
a:hover {
|
||||
filter: brightness(85%);
|
||||
&.disabled {
|
||||
filter: grayscale(100%);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.filled {
|
||||
background-color: var(--primary);
|
||||
color: var(--text-three);
|
||||
}
|
||||
|
||||
&.tonal {
|
||||
background-color: var(--surface-four);
|
||||
}
|
||||
|
||||
&.text {
|
||||
background-color: transparent;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.outlined {
|
||||
border: 2px solid var(--primary);
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
export let fullscreen = false;
|
||||
export let notDismissible = false;
|
||||
|
||||
let element: HTMLDivElement;
|
||||
let element: HTMLDialogElement;
|
||||
let y = 0;
|
||||
|
||||
function parseScroll() {
|
||||
@@ -28,9 +28,8 @@
|
||||
transition:fade={{ easing: quadInOut, duration: 150 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
<dialog
|
||||
class="modal"
|
||||
role="dialog"
|
||||
class:fullscreen
|
||||
class:scrolled={y > 10}
|
||||
aria-modal="true"
|
||||
@@ -69,7 +68,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -121,6 +120,7 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: none;
|
||||
border-radius: 26px;
|
||||
background-color: var(--surface-seven);
|
||||
display: flex;
|
||||
|
||||
19
src/lib/components/Divider.svelte
Normal file
19
src/lib/components/Divider.svelte
Normal 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 |
82
src/lib/components/Gallery.svelte
Normal file
82
src/lib/components/Gallery.svelte
Normal 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>
|
||||
76
src/lib/components/ImageModal.svelte
Normal file
76
src/lib/components/ImageModal.svelte
Normal 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>
|
||||
65
src/lib/components/Input.svelte
Normal file
65
src/lib/components/Input.svelte
Normal 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>
|
||||
@@ -22,7 +22,7 @@
|
||||
height: 40vh;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 820px) {
|
||||
@media screen and (max-height: 780px) {
|
||||
svg {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
66
src/lib/stores.ts
Normal file
66
src/lib/stores.ts
Normal 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
|
||||
@@ -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 {
|
||||
name: string;
|
||||
avatar_url: string;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { events as themeEvents } from '$util/themeEvents';
|
||||
|
||||
import { RV_GOOGLE_TAG_MANAGER_ID } from '$env/static/public';
|
||||
import FooterHost from '$layout/Footer/FooterHost.svelte';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -108,21 +109,19 @@
|
||||
</noscript>
|
||||
{/if}
|
||||
|
||||
<Dialogue bind:modalOpen={showConsentModal} notDismissible>
|
||||
<svelte:fragment slot="title">It's your choice</svelte:fragment>
|
||||
<svelte:fragment slot="description">
|
||||
We use analytics to improve your experience on this site. By clicking "Allow", you allow us to
|
||||
collect anonymous data about your visit.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button type="text" on:click={() => rememberChoice(false)}>Deny</Button>
|
||||
<Button type="filled" on:click={() => rememberChoice(true)}>Allow</Button>
|
||||
</svelte:fragment>
|
||||
</Dialogue>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NavHost />
|
||||
|
||||
<Dialogue bind:modalOpen={showConsentModal} notDismissible>
|
||||
<svelte:fragment slot="title">It's your choice</svelte:fragment>
|
||||
<svelte:fragment slot="description">
|
||||
We use analytics to improve your experience on this site. By clicking "Allow", you allow us to
|
||||
collect anonymous data about your visit.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button type="text" on:click={() => rememberChoice(false)}>Deny</Button>
|
||||
<Button type="filled" on:click={() => rememberChoice(true)}>Allow</Button>
|
||||
</svelte:fragment>
|
||||
</Dialogue>
|
||||
|
||||
<div id="skiptab">
|
||||
{#if $show_loading_animation}
|
||||
<Spinner />
|
||||
@@ -130,5 +129,5 @@
|
||||
<slot />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- <Footer> -->
|
||||
<FooterHost />
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script>
|
||||
import HeroImage from '$layout/Hero/HeroImage.svelte';
|
||||
import Home from '$layout/Hero/HeroSection.svelte';
|
||||
import Footer from '$layout/Footer/FooterHost.svelte';
|
||||
import Head from '$lib/components/Head.svelte';
|
||||
import Wave from '$lib/components/Wave.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -145,19 +144,19 @@
|
||||
</div>
|
||||
</main>
|
||||
<Wave visibility={bottomVisibility} />
|
||||
<Footer />
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-evenly;
|
||||
width: min(87%, 100rem);
|
||||
width: min(87%, 80rem);
|
||||
gap: 1rem;
|
||||
}
|
||||
main {
|
||||
overflow: hidden;
|
||||
padding: 5rem 0;
|
||||
height: max(100vh, 600px);
|
||||
min-height: max(100vh, 600px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -172,4 +171,10 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 335px) {
|
||||
main {
|
||||
padding: 2rem 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
189
src/routes/announcements/+page.svelte
Normal file
189
src/routes/announcements/+page.svelte
Normal 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>
|
||||
65
src/routes/announcements/AnnouncementBanner.svelte
Normal file
65
src/routes/announcements/AnnouncementBanner.svelte
Normal 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}
|
||||
168
src/routes/announcements/AnnouncementCard.svelte
Normal file
168
src/routes/announcements/AnnouncementCard.svelte
Normal 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>
|
||||
14
src/routes/announcements/NewHeader.svelte
Normal file
14
src/routes/announcements/NewHeader.svelte
Normal 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>
|
||||
64
src/routes/announcements/TagChip.svelte
Normal file
64
src/routes/announcements/TagChip.svelte
Normal 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>
|
||||
87
src/routes/announcements/TagsHost.svelte
Normal file
87
src/routes/announcements/TagsHost.svelte
Normal 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>
|
||||
1
src/routes/announcements/[slug]/+layout.ts
Normal file
1
src/routes/announcements/[slug]/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = false;
|
||||
51
src/routes/announcements/[slug]/+page.svelte
Normal file
51
src/routes/announcements/[slug]/+page.svelte
Normal 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 />
|
||||
152
src/routes/announcements/[slug]/AdminButtons.svelte
Normal file
152
src/routes/announcements/[slug]/AdminButtons.svelte
Normal 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>
|
||||
121
src/routes/announcements/[slug]/Announcement.svelte
Normal file
121
src/routes/announcements/[slug]/Announcement.svelte
Normal 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>
|
||||
154
src/routes/announcements/[slug]/Attachments.svelte
Normal file
154
src/routes/announcements/[slug]/Attachments.svelte
Normal 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>
|
||||
38
src/routes/announcements/[slug]/Author.svelte
Normal file
38
src/routes/announcements/[slug]/Author.svelte
Normal 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>
|
||||
112
src/routes/announcements/[slug]/Content.svelte
Normal file
112
src/routes/announcements/[slug]/Content.svelte
Normal 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>
|
||||
67
src/routes/announcements/[slug]/Date.svelte
Normal file
67
src/routes/announcements/[slug]/Date.svelte
Normal 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>
|
||||
117
src/routes/announcements/[slug]/Tags.svelte
Normal file
117
src/routes/announcements/[slug]/Tags.svelte
Normal 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>
|
||||
41
src/routes/announcements/[slug]/Title.svelte
Normal file
41
src/routes/announcements/[slug]/Title.svelte
Normal 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>
|
||||
@@ -3,14 +3,13 @@
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
import ContributorHost from './ContributorSection.svelte';
|
||||
import Footer from '$layout/Footer/FooterHost.svelte';
|
||||
import Head from '$lib/components/Head.svelte';
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
|
||||
import { queries } from '$data/api';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
const query = createQuery(['contributors'], queries.contributors);
|
||||
const query = createQuery(queries.contributors());
|
||||
</script>
|
||||
|
||||
<Head
|
||||
@@ -62,8 +61,6 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
.repos {
|
||||
display: flex;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
import Head from '$lib/components/Head.svelte';
|
||||
import Footer from '$layout/Footer/FooterHost.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Snackbar from '$lib/components/Snackbar.svelte';
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
@@ -22,8 +21,8 @@
|
||||
|
||||
import { supportsWebP } from '$util/supportsWebP';
|
||||
|
||||
const teamQuery = createQuery(['team'], queries.team);
|
||||
const aboutQuery = createQuery(['about'], queries.about);
|
||||
const teamQuery = createQuery(queries.team());
|
||||
const aboutQuery = createQuery(queries.about());
|
||||
|
||||
let qrCodeDialogue = false;
|
||||
let cryptoDialogue = false;
|
||||
@@ -205,8 +204,6 @@
|
||||
<svelte:fragment slot="text">Address copied to clipboard</svelte:fragment>
|
||||
</Snackbar>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style lang="scss">
|
||||
main {
|
||||
display: flex;
|
||||
|
||||
@@ -12,12 +12,11 @@
|
||||
import Head from '$lib/components/Head.svelte';
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Footer from '$layout/Footer/FooterHost.svelte';
|
||||
import Picture from '$lib/components/Picture.svelte';
|
||||
import Dialogue from '$lib/components/Dialogue.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const query = createQuery(['manager'], queries.manager);
|
||||
const query = createQuery(queries.manager());
|
||||
|
||||
let warning: string;
|
||||
let warningDialogue = false;
|
||||
@@ -85,7 +84,7 @@
|
||||
</svelte:fragment>
|
||||
</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>
|
||||
<p>Patch your favourite apps, right on your device.</p>
|
||||
<div class="buttons">
|
||||
@@ -112,9 +111,7 @@
|
||||
<div class="screenshot">
|
||||
<Picture data={manager_screenshot} alt="Manager Screenshot" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.center {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { derived, readable, type Readable } from 'svelte/store';
|
||||
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 { queries } from '$data/api';
|
||||
@@ -14,15 +14,16 @@
|
||||
import PackageMenu from './PackageMenu.svelte';
|
||||
import Package from './Package.svelte';
|
||||
import PatchItem from './PatchItem.svelte';
|
||||
import Footer from '$layout/Footer/FooterHost.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
import FilterChip from '$lib/components/FilterChip.svelte';
|
||||
import Dialogue from '$lib/components/Dialogue.svelte';
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
import Fuse from 'fuse.js';
|
||||
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;
|
||||
|
||||
@@ -39,49 +40,26 @@
|
||||
let mobilePackages = false;
|
||||
let showAllVersions = false;
|
||||
|
||||
function checkCompatibility(patch: Patch, pkg: string) {
|
||||
if (pkg === '') {
|
||||
return false;
|
||||
}
|
||||
return !!patch.compatiblePackages?.find((compat) => compat.name === pkg);
|
||||
}
|
||||
function filterPatches(patches: Patch[], pkg: string, search?: string): Patch[] {
|
||||
const patchFilter = createFilter(patches, {
|
||||
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
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
.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;
|
||||
return patchFilter(pkg, search);
|
||||
}
|
||||
|
||||
// Make sure we don't have to filter the patches after every key press
|
||||
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 = () => {
|
||||
displayedTerm = searchTerm;
|
||||
|
||||
@@ -184,7 +162,7 @@
|
||||
</aside>
|
||||
|
||||
<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) -->
|
||||
{#key selectedPkg || displayedTerm}
|
||||
<div in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
|
||||
@@ -195,7 +173,6 @@
|
||||
</div>
|
||||
</Query>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
main {
|
||||
|
||||
7
src/util/debounce.ts
Normal file
7
src/util/debounce.ts
Normal 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
34
src/util/filter.ts
Normal 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
3
src/util/fromNow.ts
Normal 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
8
src/util/isValidUrl.ts
Normal 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
7
src/util/relativeTime.ts
Normal 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');
|
||||
};
|
||||
Reference in New Issue
Block a user