chore: Merge branch dev to main (#293)

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

10
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@tanstack/query-sync-storage-persister": "^4.36.1",
"@tanstack/svelte-query": "^4.36.1",
"datetrigger": "^1.1.1",
"moment": "^2.30.1",
"svelte-material-icons": "^3.0.5"
},
"devDependencies": {
@@ -3429,6 +3430,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",

View File

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

74
pnpm-lock.yaml generated
View File

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

View File

@@ -1,26 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta
name="robots"
content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1"
/>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- OpenGraph -->
<meta property="og:type" content="website" />
<meta property="og:image" content="/logo.png" />
<!-- OpenGraph -->
<meta property="og:type" content="website" />
<meta property="og:image" content="/logo.png" />
<!-- Twitter -->
<meta name="twitter:image" itemprop="image" content="/logo.png" />
<meta name="twitter:card" content="summary" />
<!-- Twitter -->
<meta name="twitter:image" itemprop="image" content="/logo.png" />
<meta name="twitter:card" content="summary" />
%sveltekit.head%
</head>
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>
<body>
%sveltekit.body%
</body>
</html>

View File

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

View File

@@ -9,8 +9,12 @@ import type {
DonationPlatform,
CryptoWallet,
Social,
About
About,
ResponseAnnouncement,
Announcement,
Tags
} from '$lib/types';
import { get_access_token, is_logged_in, UnauthenticatedError } from '$lib/auth';
export type ContributorsData = { contributables: Contributable[] };
export type PatchesData = { patches: Patch[]; packages: string[] };
@@ -19,10 +23,70 @@ export type TeamData = { members: TeamMember[] };
export type AboutData = { about: About };
export type DonationData = { wallets: CryptoWallet[]; platforms: DonationPlatform[] };
export type SocialsData = { socials: Social[] };
export type AnnouncementsData = { announcements: ResponseAnnouncement[] };
type GetAnnouncementsOptions = Partial<{
tags: string[];
count: number;
cursor: number;
}>;
export function build_url(endpoint: string) {
// //////v4/contributors -> v4/contributors
endpoint = endpoint.replace(/^\/+/, '');
// v4/contributors -> contributors
if (endpoint.startsWith(settings.API_VERSION)) endpoint = endpoint.split('/').slice(1).join('/');
return `${settings.api_base_url()}/${settings.API_VERSION}/${endpoint}`;
}
function build_headers() {
const access_token_data = get_access_token();
return {
'Content-Type': 'application/json',
Authorization: access_token_data ? `Bearer ${access_token_data.token}` : ''
};
}
async function get_json(endpoint: string) {
const url = `${settings.api_base_url()}/${endpoint}`;
return await fetch(url).then((r) => r.json());
return await fetch(build_url(endpoint)).then((r) => r.json());
}
async function post_json(endpoint: string, body?: any) {
if (!is_logged_in()) throw new UnauthenticatedError();
const headers = build_headers();
return await fetch(build_url(endpoint), {
method: 'POST',
headers,
body: body ? JSON.stringify(body) : ''
}).then((r) => {
return r.headers.get('content-length') === '0' ? null : r.json();
});
}
async function patch_json(endpoint: string, body?: any) {
if (!is_logged_in()) throw new UnauthenticatedError();
const headers = build_headers();
return await fetch(build_url(endpoint), {
method: 'PATCH',
headers,
body: body ? JSON.stringify(body) : ''
}).then((r) => {
return r.headers.get('content-length') === '0' ? null : r.json();
});
}
async function delete_json(endpoint: string, body?: any) {
if (!is_logged_in()) throw new UnauthenticatedError();
const headers = build_headers();
return await fetch(build_url(endpoint), {
method: 'DELETE',
headers,
body: body ? JSON.stringify(body) : ''
}).then((r) => {
return r.headers.get('content-length') === '0' ? null : r.json();
});
}
async function contributors(): Promise<ContributorsData> {
@@ -74,6 +138,58 @@ async function about(): Promise<AboutData> {
return { about: json };
}
async function announcements(options: GetAnnouncementsOptions = {}): Promise<AnnouncementsData> {
const url = new URL(build_url('announcements'));
if (options.tags && options.tags.length > 0) url.searchParams.set('tags', options.tags.join(','));
if (options.count) url.searchParams.set('count', String(options.count));
if (options.cursor) url.searchParams.set('cursor', String(options.cursor));
const announcements = (await get_json('announcements')) as ResponseAnnouncement[];
return { announcements };
}
async function get_announcement_by_id(id: number): Promise<{ announcement: ResponseAnnouncement }> {
return { announcement: (await get_json(`announcements/${id}`)) as ResponseAnnouncement };
}
async function announcementTags(): Promise<{ tags: Tags }> {
return { tags: (await get_json(`announcements/tags`)) as Tags };
}
function show_error_alert(res: Response) {
alert(`A ${res.status < 500 ? 'user' : 'server'} error occurred. Please try again.`);
}
export async function create_announcement(announcement: Announcement) {
await post_json('announcements', announcement).catch(show_error_alert);
}
export async function update_announcement(id: number, announcement: Announcement) {
await patch_json(`announcements/${id}`, announcement).catch(show_error_alert);
}
export async function delete_announcement(id: number) {
await delete_json(`announcements/${id}`).catch(show_error_alert);
}
export async function archive_announcement(id: number) {
await post_json(`announcements/${id}/archive`).catch(show_error_alert);
}
export async function unarchive_announcement(id: number) {
await post_json(`announcements/${id}/unarchive`).catch(show_error_alert);
}
export const admin = {
create_announcement,
update_announcement,
delete_announcement,
archive_announcement,
unarchive_announcement
};
async function ping(): Promise<boolean> {
try {
const res = await fetch(`${settings.api_base_url()}/v4/ping`, { method: 'HEAD' });
@@ -85,34 +201,48 @@ async function ping(): Promise<boolean> {
export const staleTime = 5 * 60 * 1000;
export const queries = {
manager: {
manager: () => ({
queryKey: ['manager'],
queryFn: manager,
staleTime
},
patches: {
}),
patches: () => ({
queryKey: ['patches'],
queryFn: patches,
staleTime
},
contributors: {
}),
contributors: () => ({
queryKey: ['contributors'],
queryFn: contributors,
staleTime
},
team: {
}),
team: () => ({
queryKey: ['team'],
queryFn: team,
staleTime
},
about: {
}),
about: () => ({
queryKey: ['info'],
queryFn: about,
staleTime
},
ping: {
}),
announcements: () => ({
queryKey: ['announcements'],
queryFn: () => announcements(),
staleTime
}),
announcementById: (id: number) => ({
queryKey: ['announcementById', id],
queryFn: () => get_announcement_by_id(id)
}),
announcementTags: () => ({
queryKey: ['announcementTags'],
queryFn: () => announcementTags(),
staleTime
}),
ping: () => ({
queryKey: ['ping'],
queryFn: ping,
staleTime
}
})
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,66 @@
import { readable, writable } from 'svelte/store';
import { is_logged_in, get_access_token } from './auth';
import { browser } from '$app/environment';
type AdminLoginInfo =
| {
logged_in: true;
expires: number;
logged_in_previously: boolean;
}
| {
logged_in: false;
expires: undefined;
logged_in_previously: boolean;
};
const admin_login_info = (): AdminLoginInfo => {
if (is_logged_in())
return {
logged_in: true,
expires: get_access_token()!.expires,
logged_in_previously: !!get_access_token()?.token
};
else
return {
logged_in: false,
expires: undefined,
logged_in_previously: !!get_access_token()?.token
};
};
export const admin_login = readable<AdminLoginInfo>(admin_login_info(), (set) => {
const checkLoginStatus = () => set(admin_login_info());
checkLoginStatus();
const interval = setInterval(checkLoginStatus, 100);
return () => clearInterval(interval);
});
export const read_announcements = writable<Set<number>>(new Set(), (set) => {
if (!browser) return;
const key = 'read_announcements';
const data = localStorage.getItem(key);
const parsedArray = data ? JSON.parse(data) : [];
const currentState = new Set(parsedArray);
const updateStoreState = () => {
set(currentState);
};
const handleLocalStorageUpdate = (e: StorageEvent) => {
if (e.key === key) updateStoreState();
};
window.addEventListener('storage', handleLocalStorageUpdate);
updateStoreState();
return () => {
window.removeEventListener('storage', handleLocalStorageUpdate);
localStorage.setItem(key, JSON.stringify(Array.from(currentState)));
};
});
export const passed_login_with_creds = writable(false); // will only change when the user INPUTS the credentials, not if the session is just valid

View File

@@ -1,3 +1,19 @@
export type ResponseAnnouncement = {
archived_at?: string;
attachments?: string[];
author?: string;
tags?: string[];
content?: string;
created_at: string;
id: number;
level?: number;
title: string;
};
export type Announcement = Omit<ResponseAnnouncement, 'id'>;
export type Tags = { name: string }[];
export interface Contributor {
name: string;
avatar_url: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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