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