mirror of
https://github.com/ReVanced/revanced-website.git
synced 2026-01-10 05:06:18 +00:00
feat: Add announcements (#257)
* feat: init /announcements route
+ update dependecies
+ add types
+ add components
* fix: use page store to get the query params
* feat: filter ann by channel
+ revert queries lib to v4
* feat: use page store to select channel chip
* feat: add content slot
* feat: add top row with all channels
* feat: add Gallery component
* feat: style ChannelChip
* feat: add NewChip
* feat: add read announcement logic
* refactor: Move components to the announcements route folder
* feat: Style the announcement cards and use mansory layout
* fix: resolve errors
* chore: Minor refactor and add more placeholder announcements
* fix: Remove unused import
* fix: use correct types + import component
* fix: Announcements masonry layout not displaying anything
* feat: add unread marker to announcement card
* refactor: simplify read logic
* feat: add gap to channels select
* refactor: rename var
* feat: hide actions div if not admin
* refactor: change gear icon to 3 dots icon
* refactor: reduce content preview length
* refactor: remove parentheses and newlines
* feat: init auth module
* refactor: remove newline
* feat: add post, patch and delete methods with auth
+ move get/set token to auth module
+ add UnauthenticatedError class
* feat: add login fn
* feat: add login btn
* chore: update dependencies to fix vulnerability
* refactor: remove card banner
* feat: init login functionality in dom
* feat: add login request and token storage
* refactor: simplify login
* refactor: add comment
* feat: finish auth in dom
* refactor: remove commented code
* feat: add code-side digest auth
* fix: return initial response if 200
* refactor: remove log
* feat: init login modal
* feat: more form styles
* fix: fix auth header
* feat: hide login popup if logged in successfully
* feat: animate input label
* feat: styling (fix inputs overlap)
* fix: add body param to authored requests
* feat: add announcement-related helper functions
* fix: use ? instead of | undefined
* feat: Implement majority of UI
* feat: add Input component
* fix: fix input font size
* feat: add HA1 sha256 to session storage
* feat: add login success dialog
* fix: fix centered text
* fix: use momentjs
* fix: fix ts error
* feat: set announcement read on click
* feat: card check if user is admin
* fix: fix svg size
* feat: add mobile styling
* feat: add session expired modal
* fix: close sess exp modal on new login
* feat: Search
* refactor: Minor cleanup
* feat: Replace 'Sudo Login' with 'Admin Login' and remove icon
* feat: update api to v4
* refactor: move ApiAnnouncementCreate to types file
* fix: minor code fixes
* feat: update Announcement type to API v4 spec
* feat: add Gallery component
* feat: use NewBadge instead of UnreadDot
* feat: Prefetch announcements & use new /announcements/id endpoint
* refactor: Change all references of channels to tags
* refactor: Simplify prefetch query arguments
* fix: fix new badge
* feat: add gallery to ann page
* refactor: change image preview aspect ratio
* feat: add fullscreen image transition
* feat: move gallery inside announcement container
* refactor: use store to check for valid login
* refactor: remove unused css class
* feat: add editing and deleting announcements
TO FIX: `goto('/announcements')` doesn't work
* feat: add unsaved changes check
* feat: add warning before deleting announcement
* refactor: update texts and coloring of warning modal
* refactor: remove dot from red coloring
* refactor: remove red color
* refactor: minor tweaks
* fix: fix json parsing errors when there's no content in response
* refactor: switch buttons position
* feat: add more props to Input
* feat: add wrong credentials message
* feat: Show tags in announcement cards
* feat: Improve NEW header
* fix: Don't show 2nd swiggly line if there's no attachments
* fix: Attachment border radius on unread announcements
* feat: Add create announcement button
* fix: Hide tags section in AnnouncementCard when there's no tags
* fix: Cleanup tags handling and don't show TagsHost if there's no tags
* feat: Add editing and creating announcements and major cleanup
* feat: Managing attachments
* refactor: Split page into components and decrease repetition
* feat: Support modifying createdAt date
* fix: Ensure correct default date input value when editing announcements
* refactor: Minor cleanup
* fix: Save all changes on submit
* feat: Managing tags
* feat: Managing archived status
* refactor: Reduce repetition
* fix: Don't show cancel edit button on creation
* fix: Disable prerendering announcements to fix building
* optimize
* feat: add read announcements store + typing fixes
* refactor: add newline
* fix: add more types
* fix: Trying to write to readable and minor cleanup
* fix: Leave announcement when clicking announcement in the NavBar
* feat: Handle nullability and cleanup
* feat: Wrap icon button in Button and cleanup
* feat: Relative time within 7d and cleanup
* feat: Don't use red text on session expired dialog
* refactor: Reduce repetition
* refactor: Element -> Input
* fix: Reset preview state after cancelling and improve reactivitiy
* feat: Improve admin login in settings design
* fix: Use Moment.js for date handling to resolve timezone offsets and formatting issues
* fix: Don't display content in AnnouncementCard if undefined
* fix: Use goto instead of history
* feat: Prevent announcement submission without a title
* refactor: Minor cleanup
* fix: Unable to create attachments
* refactor: Use correct type
* chore: Remove unused CSS
* fix: Improve tag submission handling on Enter key press
I'm confused
* feat: Improve validity check
* feat: Add attachment validation and improve design
* fix: Inverted announcement validity check
* fix: Empty state for new attachments
* fix: Don't overflow ontop of bin icon
* fix: Improve invalid new attachment handling
* feat: Swap date and content in AnnouncementCard
* feat: Complete login on Enter key press in password input
* fix: Reversed validation logic
* feat: Invalidate cache on Announcement create/delete
* fix: Inline icon in Create button to display the correct color
* feat: Style AnnouncementCard content
* feat: Invalidate cache after save and ensure cache is invalidated after operation
* refactor: Use correct Utils location
* refactor: Remove unused parameter
* feat: Relative date in AnnouncementCard
* feat: Improve mobile responsivity
* feat: Hide archived announcements for non-admin viewers
* feat: Distinguish archived announcements
* refactor: update admin panel message
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
* refactor: update admin panel title
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
* refactor: Reduce repetition
* fix: Update content after saving
* feat: Handle nullable content
* feat: Archived posts section
* fix: Improve nullability handling
* refactor: Improve relativeTime configurability
* Merge branch 'dev' into pr/madkarmaa/257
* feat: Migrate remaining icons
* feat: Use outline icons
* feat: Wrap long titles
* fix: fix typo
* fix: don't cache creds
* refactor: remove stale time for announcements
* feat: show error alert if admin function fails
* feat: Add slugs to announcement URLs for better readability and SEO
"A site’s URL structure should be as simple as possible. Consider organizing your content so that URLs are constructed logically and in a manner that is most intelligible to humans." — Google Search Central
* feat: add announcement banner
* feat: Improve banner designs
* fix: Close button color and banner not closing
* fix: Sticky navbar, move modals to own components, tidy up CSS, use store for `passed_login_with_creds`
* chore: Delete unused icons
---------
Co-authored-by: Ushie <ushiekane@gmail.com>
Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@tanstack/query-sync-storage-persister": "^4.36.1",
|
||||
"@tanstack/svelte-query": "^4.36.1",
|
||||
"datetrigger": "^1.1.1",
|
||||
"moment": "^2.30.1",
|
||||
"svelte-material-icons": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3429,6 +3430,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@tanstack/query-sync-storage-persister": "^4.36.1",
|
||||
"@tanstack/svelte-query": "^4.36.1",
|
||||
"datetrigger": "^1.1.1",
|
||||
"moment": "^2.30.1",
|
||||
"svelte-material-icons": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
74
pnpm-lock.yaml
generated
74
pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
datetrigger:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
moment:
|
||||
specifier: ^2.30.1
|
||||
version: 2.30.1
|
||||
svelte-material-icons:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5(svelte@4.2.18)
|
||||
@@ -1192,6 +1195,9 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
moment@2.30.1:
|
||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1241,8 +1247,8 @@ packages:
|
||||
periscopic@3.1.0:
|
||||
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
|
||||
|
||||
picocolors@1.0.1:
|
||||
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
|
||||
picocolors@1.1.0:
|
||||
resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
@@ -1276,8 +1282,8 @@ packages:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss@8.4.41:
|
||||
resolution: {integrity: sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==}
|
||||
postcss@8.4.47:
|
||||
resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
@@ -1343,8 +1349,8 @@ packages:
|
||||
resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
semver@7.6.2:
|
||||
resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==}
|
||||
semver@7.6.3:
|
||||
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1375,8 +1381,8 @@ packages:
|
||||
resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
@@ -2051,7 +2057,7 @@ snapshots:
|
||||
fast-glob: 3.3.2
|
||||
is-glob: 4.0.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
ts-api-utils: 1.3.0(typescript@5.7.2)
|
||||
optionalDependencies:
|
||||
typescript: 5.7.2
|
||||
@@ -2175,7 +2181,7 @@ snapshots:
|
||||
css-tree@2.3.1:
|
||||
dependencies:
|
||||
mdn-data: 2.0.30
|
||||
source-map-js: 1.2.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
@@ -2229,7 +2235,7 @@ snapshots:
|
||||
eslint-compat-utils@0.5.1(eslint@9.16.0):
|
||||
dependencies:
|
||||
eslint: 9.16.0
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
|
||||
eslint-config-prettier@9.1.0(eslint@9.16.0):
|
||||
dependencies:
|
||||
@@ -2243,11 +2249,11 @@ snapshots:
|
||||
eslint-compat-utils: 0.5.1(eslint@9.16.0)
|
||||
esutils: 2.0.3
|
||||
known-css-properties: 0.35.0
|
||||
postcss: 8.4.41
|
||||
postcss-load-config: 3.1.4(postcss@8.4.41)
|
||||
postcss-safe-parser: 6.0.0(postcss@8.4.41)
|
||||
postcss: 8.4.47
|
||||
postcss-load-config: 3.1.4(postcss@8.4.47)
|
||||
postcss-safe-parser: 6.0.0(postcss@8.4.47)
|
||||
postcss-selector-parser: 6.1.2
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
svelte-eslint-parser: 0.43.0(svelte@4.2.18)
|
||||
optionalDependencies:
|
||||
svelte: 4.2.18
|
||||
@@ -2500,6 +2506,8 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
moment@2.30.1: {}
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
mrmime@2.0.0: {}
|
||||
@@ -2544,35 +2552,35 @@ snapshots:
|
||||
estree-walker: 3.0.3
|
||||
is-reference: 3.0.2
|
||||
|
||||
picocolors@1.0.1: {}
|
||||
picocolors@1.1.0: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
postcss-load-config@3.1.4(postcss@8.4.41):
|
||||
postcss-load-config@3.1.4(postcss@8.4.47):
|
||||
dependencies:
|
||||
lilconfig: 2.1.0
|
||||
yaml: 1.10.2
|
||||
optionalDependencies:
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
|
||||
postcss-safe-parser@6.0.0(postcss@8.4.41):
|
||||
postcss-safe-parser@6.0.0(postcss@8.4.47):
|
||||
dependencies:
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
|
||||
postcss-scss@4.0.9(postcss@8.4.41):
|
||||
postcss-scss@4.0.9(postcss@8.4.47):
|
||||
dependencies:
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss@8.4.41:
|
||||
postcss@8.4.47:
|
||||
dependencies:
|
||||
nanoid: 3.3.7
|
||||
picocolors: 1.0.1
|
||||
source-map-js: 1.2.0
|
||||
picocolors: 1.1.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
@@ -2629,7 +2637,7 @@ snapshots:
|
||||
dependencies:
|
||||
chokidar: 4.0.3
|
||||
immutable: 5.0.3
|
||||
source-map-js: 1.2.0
|
||||
source-map-js: 1.2.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher': 2.5.0
|
||||
|
||||
@@ -2639,7 +2647,7 @@ snapshots:
|
||||
|
||||
semiver@1.1.0: {}
|
||||
|
||||
semver@7.6.2: {}
|
||||
semver@7.6.3: {}
|
||||
|
||||
set-cookie-parser@2.7.0: {}
|
||||
|
||||
@@ -2647,7 +2655,7 @@ snapshots:
|
||||
dependencies:
|
||||
color: 4.2.3
|
||||
detect-libc: 2.0.3
|
||||
semver: 7.6.2
|
||||
semver: 7.6.3
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.33.4
|
||||
'@img/sharp-darwin-x64': 0.33.4
|
||||
@@ -2696,7 +2704,7 @@ snapshots:
|
||||
mrmime: 2.0.0
|
||||
totalist: 3.0.1
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
@@ -2711,7 +2719,7 @@ snapshots:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
chokidar: 4.0.3
|
||||
fdir: 6.4.2
|
||||
picocolors: 1.0.1
|
||||
picocolors: 1.1.0
|
||||
sade: 1.8.1
|
||||
svelte: 4.2.18
|
||||
typescript: 5.7.2
|
||||
@@ -2723,8 +2731,8 @@ snapshots:
|
||||
eslint-scope: 7.2.2
|
||||
eslint-visitor-keys: 3.4.3
|
||||
espree: 9.6.1
|
||||
postcss: 8.4.41
|
||||
postcss-scss: 4.0.9(postcss@8.4.41)
|
||||
postcss: 8.4.47
|
||||
postcss-scss: 4.0.9(postcss@8.4.47)
|
||||
optionalDependencies:
|
||||
svelte: 4.2.18
|
||||
|
||||
@@ -2813,7 +2821,7 @@ snapshots:
|
||||
vite@5.3.3(sass@1.81.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.4.41
|
||||
postcss: 8.4.47
|
||||
rollup: 4.20.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
16
src/app.scss
16
src/app.scss
@@ -74,6 +74,7 @@ body {
|
||||
|
||||
--red-one: hsl(333, 84%, 62%);
|
||||
--red-two: hsl(357, 74%, 60%);
|
||||
--red-three: hsl(2, 68%, 83%);
|
||||
|
||||
--yellow-one: hsl(59, 100%, 72%);
|
||||
|
||||
@@ -86,6 +87,10 @@ body {
|
||||
background-color: var(--tertiary);
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
/*-----headings-----*/
|
||||
|
||||
h1 {
|
||||
@@ -173,7 +178,13 @@ hr {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
input {
|
||||
textarea {
|
||||
resize: vertical;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -181,6 +192,7 @@ input {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ import type {
|
||||
DonationPlatform,
|
||||
CryptoWallet,
|
||||
Social,
|
||||
About
|
||||
About,
|
||||
ResponseAnnouncement,
|
||||
Announcement,
|
||||
Tags
|
||||
} from '$lib/types';
|
||||
import { get_access_token, is_logged_in, UnauthenticatedError } from '$lib/auth';
|
||||
|
||||
export type ContributorsData = { contributables: Contributable[] };
|
||||
export type PatchesData = { patches: Patch[]; packages: string[] };
|
||||
@@ -19,10 +23,70 @@ export type TeamData = { members: TeamMember[] };
|
||||
export type AboutData = { about: About };
|
||||
export type DonationData = { wallets: CryptoWallet[]; platforms: DonationPlatform[] };
|
||||
export type SocialsData = { socials: Social[] };
|
||||
export type AnnouncementsData = { announcements: ResponseAnnouncement[] };
|
||||
|
||||
type GetAnnouncementsOptions = Partial<{
|
||||
tags: string[];
|
||||
count: number;
|
||||
cursor: number;
|
||||
}>;
|
||||
|
||||
export function build_url(endpoint: string) {
|
||||
// //////v4/contributors -> v4/contributors
|
||||
endpoint = endpoint.replace(/^\/+/, '');
|
||||
|
||||
// v4/contributors -> contributors
|
||||
if (endpoint.startsWith(settings.API_VERSION)) endpoint = endpoint.split('/').slice(1).join('/');
|
||||
|
||||
return `${settings.api_base_url()}/${settings.API_VERSION}/${endpoint}`;
|
||||
}
|
||||
|
||||
function build_headers() {
|
||||
const access_token_data = get_access_token();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: access_token_data ? `Bearer ${access_token_data.token}` : ''
|
||||
};
|
||||
}
|
||||
|
||||
async function get_json(endpoint: string) {
|
||||
const url = `${settings.api_base_url()}/${endpoint}`;
|
||||
return await fetch(url).then((r) => r.json());
|
||||
return await fetch(build_url(endpoint)).then((r) => r.json());
|
||||
}
|
||||
|
||||
async function post_json(endpoint: string, body?: any) {
|
||||
if (!is_logged_in()) throw new UnauthenticatedError();
|
||||
const headers = build_headers();
|
||||
return await fetch(build_url(endpoint), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : ''
|
||||
}).then((r) => {
|
||||
return r.headers.get('content-length') === '0' ? null : r.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function patch_json(endpoint: string, body?: any) {
|
||||
if (!is_logged_in()) throw new UnauthenticatedError();
|
||||
const headers = build_headers();
|
||||
return await fetch(build_url(endpoint), {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : ''
|
||||
}).then((r) => {
|
||||
return r.headers.get('content-length') === '0' ? null : r.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function delete_json(endpoint: string, body?: any) {
|
||||
if (!is_logged_in()) throw new UnauthenticatedError();
|
||||
const headers = build_headers();
|
||||
return await fetch(build_url(endpoint), {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : ''
|
||||
}).then((r) => {
|
||||
return r.headers.get('content-length') === '0' ? null : r.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function contributors(): Promise<ContributorsData> {
|
||||
@@ -74,6 +138,58 @@ async function about(): Promise<AboutData> {
|
||||
return { about: json };
|
||||
}
|
||||
|
||||
async function announcements(options: GetAnnouncementsOptions = {}): Promise<AnnouncementsData> {
|
||||
const url = new URL(build_url('announcements'));
|
||||
|
||||
if (options.tags && options.tags.length > 0) url.searchParams.set('tags', options.tags.join(','));
|
||||
if (options.count) url.searchParams.set('count', String(options.count));
|
||||
if (options.cursor) url.searchParams.set('cursor', String(options.cursor));
|
||||
|
||||
const announcements = (await get_json('announcements')) as ResponseAnnouncement[];
|
||||
|
||||
return { announcements };
|
||||
}
|
||||
|
||||
async function get_announcement_by_id(id: number): Promise<{ announcement: ResponseAnnouncement }> {
|
||||
return { announcement: (await get_json(`announcements/${id}`)) as ResponseAnnouncement };
|
||||
}
|
||||
|
||||
async function announcementTags(): Promise<{ tags: Tags }> {
|
||||
return { tags: (await get_json(`announcements/tags`)) as Tags };
|
||||
}
|
||||
|
||||
function show_error_alert(res: Response) {
|
||||
alert(`A ${res.status < 500 ? 'user' : 'server'} error occurred. Please try again.`);
|
||||
}
|
||||
|
||||
export async function create_announcement(announcement: Announcement) {
|
||||
await post_json('announcements', announcement).catch(show_error_alert);
|
||||
}
|
||||
|
||||
export async function update_announcement(id: number, announcement: Announcement) {
|
||||
await patch_json(`announcements/${id}`, announcement).catch(show_error_alert);
|
||||
}
|
||||
|
||||
export async function delete_announcement(id: number) {
|
||||
await delete_json(`announcements/${id}`).catch(show_error_alert);
|
||||
}
|
||||
|
||||
export async function archive_announcement(id: number) {
|
||||
await post_json(`announcements/${id}/archive`).catch(show_error_alert);
|
||||
}
|
||||
|
||||
export async function unarchive_announcement(id: number) {
|
||||
await post_json(`announcements/${id}/unarchive`).catch(show_error_alert);
|
||||
}
|
||||
|
||||
export const admin = {
|
||||
create_announcement,
|
||||
update_announcement,
|
||||
delete_announcement,
|
||||
archive_announcement,
|
||||
unarchive_announcement
|
||||
};
|
||||
|
||||
async function ping(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${settings.api_base_url()}/v4/ping`, { method: 'HEAD' });
|
||||
@@ -85,34 +201,48 @@ async function ping(): Promise<boolean> {
|
||||
|
||||
export const staleTime = 5 * 60 * 1000;
|
||||
export const queries = {
|
||||
manager: {
|
||||
manager: () => ({
|
||||
queryKey: ['manager'],
|
||||
queryFn: manager,
|
||||
staleTime
|
||||
},
|
||||
patches: {
|
||||
}),
|
||||
patches: () => ({
|
||||
queryKey: ['patches'],
|
||||
queryFn: patches,
|
||||
staleTime
|
||||
},
|
||||
contributors: {
|
||||
}),
|
||||
contributors: () => ({
|
||||
queryKey: ['contributors'],
|
||||
queryFn: contributors,
|
||||
staleTime
|
||||
},
|
||||
team: {
|
||||
}),
|
||||
team: () => ({
|
||||
queryKey: ['team'],
|
||||
queryFn: team,
|
||||
staleTime
|
||||
},
|
||||
about: {
|
||||
}),
|
||||
about: () => ({
|
||||
queryKey: ['info'],
|
||||
queryFn: about,
|
||||
staleTime
|
||||
},
|
||||
ping: {
|
||||
}),
|
||||
announcements: () => ({
|
||||
queryKey: ['announcements'],
|
||||
queryFn: () => announcements(),
|
||||
staleTime
|
||||
}),
|
||||
announcementById: (id: number) => ({
|
||||
queryKey: ['announcementById', id],
|
||||
queryFn: () => get_announcement_by_id(id)
|
||||
}),
|
||||
announcementTags: () => ({
|
||||
queryKey: ['announcementTags'],
|
||||
queryFn: () => announcementTags(),
|
||||
staleTime
|
||||
}),
|
||||
ping: () => ({
|
||||
queryKey: ['ping'],
|
||||
queryFn: ping,
|
||||
staleTime
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ function set_status_url(apiUrl: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export const API_VERSION = 'v4';
|
||||
|
||||
// Get base URL
|
||||
export function api_base_url(): string {
|
||||
if (browser) {
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
import FooterSection from './FooterSection.svelte';
|
||||
|
||||
import { RV_DMCA_GUID } from '$env/static/public';
|
||||
import { onMount } from 'svelte';
|
||||
const aboutQuery = createQuery(['about'], queries.about);
|
||||
|
||||
const aboutQuery = createQuery(queries.about());
|
||||
|
||||
let location: string;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
86
src/layout/Navbar/Modals/LoginModal.svelte
Normal file
86
src/layout/Navbar/Modals/LoginModal.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { login } from '$lib/auth';
|
||||
|
||||
import Input from '$lib/components/Input.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Modal from '$lib/components/Dialogue.svelte';
|
||||
import { passed_login_with_creds } from '$lib/stores';
|
||||
|
||||
export let modalOpen: boolean;
|
||||
|
||||
let loginForm: HTMLFormElement;
|
||||
let wrong_credentials = false;
|
||||
|
||||
async function handle_login(e: SubmitEvent) {
|
||||
const data = new FormData(e.target as HTMLFormElement);
|
||||
|
||||
const username = data.get('username') as string;
|
||||
const password = data.get('password') as string;
|
||||
|
||||
const success = await login(username, password);
|
||||
|
||||
modalOpen = !success;
|
||||
console.log(success);
|
||||
passed_login_with_creds.set(success);
|
||||
wrong_credentials = !success;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:modalOpen>
|
||||
<div class="admin-modal-content">
|
||||
<h2>Login</h2>
|
||||
<p>This login is reserved for site administrators. Go back!</p>
|
||||
{#if wrong_credentials}
|
||||
<p style="color: var(--red-one)">Username or password do not match. Try again.</p>
|
||||
{/if}
|
||||
<form on:submit|preventDefault={handle_login} bind:this={loginForm}>
|
||||
<div>
|
||||
<Input placeholder="Username" required />
|
||||
<Input
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
onkeydown={(event) => event.key === 'Enter' && loginForm.requestSubmit()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button type="text" on:click={() => (modalOpen = !modalOpen)}>Cancel</Button>
|
||||
<!-- first paragraph of https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit -->
|
||||
<Button type="filled" on:click={() => loginForm.requestSubmit()}>Login</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.admin-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
|
||||
h2 {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
div:has(input) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div:has(svg) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
28
src/layout/Navbar/Modals/LoginSuccessfulModal.svelte
Normal file
28
src/layout/Navbar/Modals/LoginSuccessfulModal.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { fromNow } from '$util/fromNow';
|
||||
import { admin_login, passed_login_with_creds } from '$lib/stores';
|
||||
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Modal from '$lib/components/Dialogue.svelte';
|
||||
</script>
|
||||
|
||||
<Modal bind:modalOpen={$passed_login_with_creds}>
|
||||
<svelte:fragment slot="title">Successfully logged in!</svelte:fragment>
|
||||
<div class="login-success">
|
||||
This session will expire in
|
||||
<span class="exp-date">{$admin_login.logged_in ? fromNow($admin_login.expires) : '...'}</span>
|
||||
</div>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button type="filled" on:click={() => passed_login_with_creds.set(false)}>OK</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.login-success {
|
||||
color: var(--text-one);
|
||||
}
|
||||
|
||||
.exp-date {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
35
src/layout/Navbar/Modals/SessionExpiredModal.svelte
Normal file
35
src/layout/Navbar/Modals/SessionExpiredModal.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { set_access_token } from '$lib/auth';
|
||||
import { admin_login } from '$lib/stores';
|
||||
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Modal from '$lib/components/Dialogue.svelte';
|
||||
|
||||
export let loginOpen: boolean;
|
||||
|
||||
$: session_expired = $admin_login.logged_in_previously && !$admin_login.logged_in;
|
||||
|
||||
function reset_session() {
|
||||
set_access_token();
|
||||
session_expired = !session_expired;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal modalOpen={session_expired}>
|
||||
<svelte:fragment slot="title">Expired session</svelte:fragment>
|
||||
<div class="session-expired">
|
||||
This session has expired, log in again to renew or lose all access to administrative power.
|
||||
</div>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button type="text" on:click={reset_session}>OK</Button>
|
||||
<Button type="filled" on:click={() => (reset_session(), (loginOpen = !loginOpen))}>
|
||||
Login
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.session-expired {
|
||||
color: var(--text-four);
|
||||
}
|
||||
</style>
|
||||
116
src/layout/Navbar/Modals/SettingsModal.svelte
Normal file
116
src/layout/Navbar/Modals/SettingsModal.svelte
Normal file
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { fromNow } from '$util/fromNow';
|
||||
import { admin_login } from '$lib/stores';
|
||||
import { api_base_url, set_api_base_url, default_api_url } from '$data/api/settings';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Modal from '$lib/components/Dialogue.svelte';
|
||||
import Replay from 'svelte-material-icons/Replay.svelte';
|
||||
import Cog from 'svelte-material-icons/Cog.svelte';
|
||||
|
||||
export let loginOpen: boolean;
|
||||
export let modalOpen: boolean;
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let url = api_base_url();
|
||||
|
||||
function reload() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function clear_and_reload() {
|
||||
client.clear();
|
||||
// `client.clear()` does technically do this for us, but it takes a while.
|
||||
localStorage.clear();
|
||||
|
||||
reload();
|
||||
}
|
||||
|
||||
function save() {
|
||||
set_api_base_url(url);
|
||||
reload();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
url = default_api_url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:modalOpen>
|
||||
<svelte:fragment slot="icon">
|
||||
<Cog size="24px" color="var(--surface-six)" />
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="title">Settings</svelte:fragment>
|
||||
<div id="settings-content">
|
||||
<p>Configure the API for this website.</p>
|
||||
<div class="input-wrapper">
|
||||
<input name="api-url" id="api-url" type="text" bind:value={url} />
|
||||
<button id="button-reset" on:click={reset} aria-label="Reset Button">
|
||||
<Replay size="24px" color="var(--surface-six)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="buttons">
|
||||
<div class="buttons-container">
|
||||
<Button
|
||||
type="text"
|
||||
disabled={$admin_login.logged_in}
|
||||
on:click={() => ((loginOpen = !loginOpen), (modalOpen = !modalOpen))}
|
||||
>
|
||||
{$admin_login.logged_in ? `Logged in for ${fromNow($admin_login.expires)}` : 'Login'}
|
||||
</Button>
|
||||
<div class="buttons">
|
||||
<Button type="text" on:click={clear_and_reload} label="Reset Button">Reset</Button>
|
||||
<Button type="text" on:click={save} label="Save Button">Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
input {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-right: 3rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#button-reset {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,12 +14,12 @@
|
||||
if (queryKey !== null) {
|
||||
if (Array.isArray(queryKey)) {
|
||||
queryKey.forEach((key) => {
|
||||
const query = queries[key];
|
||||
const query = (queries[key] as Function)();
|
||||
dev_log('Prefetching', query);
|
||||
client.prefetchQuery(query as any);
|
||||
});
|
||||
} else {
|
||||
const query = queries[queryKey];
|
||||
const query = (queries[queryKey] as Function)();
|
||||
dev_log('Prefetching', query);
|
||||
client.prefetchQuery(query as any);
|
||||
}
|
||||
@@ -27,20 +27,40 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class:selected={href === '/' + $RouterEvents.target_url.pathname.split('/')[1]}>
|
||||
<li
|
||||
class:selected={href === '/' + $RouterEvents.target_url.pathname.split('/')[1]}
|
||||
class:unclickable={$RouterEvents.target_url.pathname === href}
|
||||
>
|
||||
<a data-sveltekit-preload-data on:mouseenter={prefetch} {href} aria-label={label}>
|
||||
<!-- Check if href is equal to the first path -->
|
||||
<span><slot /></span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
li {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
transition-timing-function: var(--bezier-one);
|
||||
transition-duration: 0.25s;
|
||||
border-radius: 10px;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--tertiary);
|
||||
color: var(--primary);
|
||||
|
||||
span {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.unclickable {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:hover {
|
||||
color: var(--text-one);
|
||||
background-color: var(--surface-three);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -60,21 +80,6 @@
|
||||
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;
|
||||
|
||||
@@ -6,77 +6,51 @@
|
||||
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}>
|
||||
<nav class:scrolled={scrollY > 10}>
|
||||
<a class="menu-btn skiptab-btn" href="#skiptab">Skip navigation</a>
|
||||
|
||||
<button
|
||||
@@ -95,28 +69,19 @@
|
||||
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>
|
||||
|
||||
<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>
|
||||
@@ -126,7 +91,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div id="secondary-navigation">
|
||||
<button on:click={() => (modalOpen = !modalOpen)} aria-label="Settings">
|
||||
<button on:click={() => (modals.settings = !modals.settings)} aria-label="Settings">
|
||||
<Cog size="20px" color="var(--surface-six)" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,6 +100,7 @@
|
||||
{/key}
|
||||
|
||||
{#if menuOpen}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="overlay mobile-only"
|
||||
transition:fade={{ duration: 350 }}
|
||||
@@ -142,30 +108,15 @@
|
||||
on:keypress={() => (menuOpen = !menuOpen)}
|
||||
/>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 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>
|
||||
<SettingsModal bind:modalOpen={modals.settings} bind:loginOpen={modals.login} />
|
||||
|
||||
<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>
|
||||
<LoginModal bind:modalOpen={modals.login} />
|
||||
|
||||
<LoginSuccessfulModal />
|
||||
|
||||
<SessionExpiredModal bind:loginOpen={modals.login} />
|
||||
|
||||
<style lang="scss">
|
||||
#logo {
|
||||
@@ -181,40 +132,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;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 2rem;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
|
||||
height: 70px;
|
||||
background-color: var(--surface-eight);
|
||||
padding: 1rem 2rem;
|
||||
width: 100%;
|
||||
|
||||
background-color: var(--surface-eight);
|
||||
}
|
||||
|
||||
#main-navigation,
|
||||
@@ -259,7 +191,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
#banner-pad {
|
||||
.banner.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -268,16 +200,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;
|
||||
@@ -336,44 +258,52 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-btn__burger {
|
||||
|
||||
&__burger {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-btn__burger,
|
||||
.menu-btn__burger::before,
|
||||
.menu-btn__burger::after {
|
||||
&,
|
||||
&::before,
|
||||
&::after {
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--surface-six);
|
||||
transition: all 0.3s var(--bezier-one);
|
||||
}
|
||||
|
||||
.menu-btn__burger::before,
|
||||
.menu-btn__burger::after {
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
.menu-btn__burger::before {
|
||||
|
||||
&::before {
|
||||
transform: translateY(-6.5px);
|
||||
}
|
||||
.menu-btn__burger::after {
|
||||
|
||||
&::after {
|
||||
transform: translateY(6.5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ANIMATION */
|
||||
.menu-btn.open .menu-btn__burger {
|
||||
&.open {
|
||||
.menu-btn__burger {
|
||||
transform: translateX(-10px);
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.menu-btn.open .menu-btn__burger::before {
|
||||
|
||||
&::before {
|
||||
transform: rotate(45deg) translate(10px, -10px);
|
||||
}
|
||||
.menu-btn.open .menu-btn__burger::after {
|
||||
|
||||
&::after {
|
||||
transform: rotate(-45deg) translate(10px, 10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skiptab-btn {
|
||||
position: fixed;
|
||||
@@ -386,9 +316,9 @@
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.skiptab-btn:focus {
|
||||
&:focus {
|
||||
left: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
17
src/layout/Navbar/StatusBanner.svelte
Normal file
17
src/layout/Navbar/StatusBanner.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Banner from '$lib/components/Banner.svelte';
|
||||
|
||||
export let statusUrl: string | null = null;
|
||||
|
||||
const handleClick = () => statusUrl && goto(statusUrl);
|
||||
</script>
|
||||
|
||||
<Banner
|
||||
title="API service is currently down"
|
||||
description="We're actively investigating and will update you shortly. We appreciate your patience."
|
||||
buttonText={statusUrl ? 'View status' : undefined}
|
||||
buttonOnClick={statusUrl ? handleClick : undefined}
|
||||
level="caution"
|
||||
permanent
|
||||
/>
|
||||
134
src/lib/auth.ts
Normal file
134
src/lib/auth.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { build_url } from '$data/api';
|
||||
|
||||
export type AuthToken = {
|
||||
token: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
type JwtPayload = {
|
||||
exp: number;
|
||||
iss: string;
|
||||
iat: number;
|
||||
};
|
||||
|
||||
export class UnauthenticatedError extends Error {
|
||||
constructor() {
|
||||
super('Unauthenticated. Cannot perform admin operations.');
|
||||
}
|
||||
}
|
||||
|
||||
// Get access token.
|
||||
export function get_access_token(): AuthToken | null {
|
||||
if (!browser) return null;
|
||||
const data = localStorage.getItem('revanced_api_access_token');
|
||||
if (data) return JSON.parse(data) as AuthToken;
|
||||
return null;
|
||||
}
|
||||
|
||||
// (Re)set access token.
|
||||
export function set_access_token(token?: AuthToken) {
|
||||
if (!token) localStorage.removeItem('revanced_api_access_token');
|
||||
else localStorage.setItem('revanced_api_access_token', JSON.stringify(token));
|
||||
}
|
||||
|
||||
// Parse a JWT token
|
||||
export function parseJwt(token: string): JwtPayload {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
return JSON.parse(jsonPayload) as JwtPayload;
|
||||
}
|
||||
|
||||
// Check if the admin is authenticated
|
||||
export function is_logged_in(): boolean {
|
||||
const token = get_access_token();
|
||||
if (!token) return false;
|
||||
return Date.now() < token.expires;
|
||||
}
|
||||
|
||||
async function digest_fetch(
|
||||
url: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
// Helper function to convert ArrayBuffer to Hex string
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Generate SHA-256 digest
|
||||
async function sha256(message: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return bufferToHex(hashBuffer);
|
||||
}
|
||||
|
||||
// Perform an initial request to get the `WWW-Authenticate` header
|
||||
const initialResponse = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {}
|
||||
});
|
||||
|
||||
if (!initialResponse.ok && initialResponse.status !== 401)
|
||||
throw new Error(`Initial request failed with status: ${initialResponse.status}`);
|
||||
|
||||
if (initialResponse.ok && initialResponse.status === 200) return initialResponse;
|
||||
|
||||
const authHeader = initialResponse.headers.get('Www-Authenticate');
|
||||
if (!authHeader || !authHeader.startsWith('Digest '))
|
||||
throw new Error('No Digest authentication header found');
|
||||
|
||||
// Parse the `WWW-Authenticate` header to extract the fields
|
||||
const authParams = authHeader
|
||||
.replace('Digest ', '')
|
||||
.split(',')
|
||||
.reduce((acc: Record<string, string>, item) => {
|
||||
const [key, value] = item.trim().split('=');
|
||||
acc[key] = value.replace(/"/g, '');
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const { realm, nonce, algorithm } = authParams;
|
||||
const method = options.method || 'GET';
|
||||
const uri = new URL(url).pathname;
|
||||
|
||||
// https://ktor.io/docs/server-digest-auth.html#flow
|
||||
const HA1 = await sha256(`${username}:${realm}:${password}`);
|
||||
const HA2 = await sha256(`${method}:${uri}`);
|
||||
|
||||
const responseHash = await sha256(`${HA1}:${nonce}:${HA2}`);
|
||||
|
||||
// Build the Authorization header
|
||||
const authHeaderDigest = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm=${algorithm}, response="${responseHash}"`;
|
||||
|
||||
// Perform the final request with the Authorization header
|
||||
const finalResponse = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: authHeaderDigest
|
||||
}
|
||||
});
|
||||
|
||||
return finalResponse;
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const res = await digest_fetch(build_url('token'), username, password);
|
||||
if (!res.ok) return false;
|
||||
|
||||
const data = await res.json();
|
||||
const payload = parseJwt(data.token);
|
||||
set_access_token({ token: data.token, expires: payload.exp * 1000 });
|
||||
return true;
|
||||
}
|
||||
@@ -1,160 +1,105 @@
|
||||
<script lang="ts">
|
||||
import Info from 'svelte-material-icons/InformationOutline.svelte';
|
||||
import Warning from 'svelte-material-icons/AlertOutline.svelte';
|
||||
import Caution from 'svelte-material-icons/AlertCircleOutline.svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import ArrowRight from 'svelte-material-icons/ArrowRight.svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
export let level: 'info' | 'warning' | 'caution' = 'info';
|
||||
export let title: string;
|
||||
export let description: string | undefined = undefined;
|
||||
export let buttonText: string | undefined = undefined;
|
||||
export let buttonOnClick: any | undefined = undefined;
|
||||
export let level: 'info' | 'caution' = 'info';
|
||||
export let permanent: boolean = false;
|
||||
|
||||
const icons = { info: Info, warning: Warning, caution: Caution };
|
||||
export let onDismiss: () => void = () => {};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let closed: boolean = false;
|
||||
|
||||
const dismissBanner = () => {
|
||||
function getVariant(level: string): 'default' | 'onDangerBackground' {
|
||||
return level === 'caution' ? 'onDangerBackground' : 'default';
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
if (onDismiss) onDismiss();
|
||||
closed = true;
|
||||
dispatch('dismissed');
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="banner-container" class:closed class:permanent>
|
||||
<div class="banner {level}">
|
||||
<div class="banner-text">
|
||||
<svelte:component this={icons[level]} size={permanent ? 22.4 : 32} />
|
||||
<span><slot /></span>
|
||||
{#if !closed}
|
||||
<div class="banner {level}" class:permanent>
|
||||
<div class="text">
|
||||
<h1 id="title">{title}</h1>
|
||||
<h2 id="description">{description}</h2>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if !permanent}
|
||||
<Button type="text" icon="close" on:click={dismissBanner}>Dismiss</Button>
|
||||
<Button type={'icon'} icon={Close} on:click={dismiss} />
|
||||
{/if}
|
||||
{#if buttonText && buttonOnClick}
|
||||
<Button variant={getVariant(level)} on:click={buttonOnClick}>
|
||||
{buttonText}
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</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;
|
||||
|
||||
&.info {
|
||||
background-color: var(--surface-four);
|
||||
color: var(--text-one);
|
||||
|
||||
#description {
|
||||
color: var(--text-four);
|
||||
}
|
||||
}
|
||||
&.caution {
|
||||
background-color: var(--red-three);
|
||||
color: #601410;
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
@media (max-width: 767px) {
|
||||
flex-direction: column;
|
||||
padding: 1.1rem 1.3rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 0.55rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.banner.info {
|
||||
background-color: var(--surface-four);
|
||||
color: var(--text-one);
|
||||
}
|
||||
|
||||
.banner.warning {
|
||||
background-color: var(--yellow-one);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.banner.warning > :global(button) {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.banner.warning > :global(button img) {
|
||||
filter: grayscale(1) brightness(0); /* Make the icon black */
|
||||
}
|
||||
|
||||
.banner.caution {
|
||||
background-color: var(--red-two);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.banner.caution > :global(button) {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.banner.caution > :global(button img) {
|
||||
filter: grayscale(1) brightness(0); /* Make the icon white */
|
||||
}
|
||||
|
||||
.banner > :global(button):hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.banner {
|
||||
flex-direction: column;
|
||||
padding: 1.1rem 1.3rem;
|
||||
}
|
||||
|
||||
.banner > :global(button) {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropDown {
|
||||
0% {
|
||||
top: -100%;
|
||||
}
|
||||
100% {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes swipeUp {
|
||||
0% {
|
||||
top: 0;
|
||||
}
|
||||
100% {
|
||||
top: -100%;
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
<script lang="ts">
|
||||
export let type: 'filled' | 'tonal' | 'text' | 'outlined';
|
||||
export let type: 'filled' | 'tonal' | 'text' | 'outlined' | 'icon' = 'filled';
|
||||
export let variant: 'default' | 'danger' | 'onDangerBackground' = 'default';
|
||||
export let functionType: typeof HTMLButtonElement.prototype.type = 'button';
|
||||
export let icon: any | undefined = undefined;
|
||||
export let iconSize = 20;
|
||||
export let iconColor = 'currentColor';
|
||||
export let href: string = '';
|
||||
export let target: string = '';
|
||||
export let label: string = '';
|
||||
export let disabled: boolean = false;
|
||||
|
||||
$: type = $$slots.default ? type : 'icon';
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} {target} class={`button-${type}`} aria-label={label}>
|
||||
<a {href} {target} aria-label={label} class={`${type} ${variant}`} class:disabled>
|
||||
<svelte:component this={icon} size={iconSize} color={iconColor} />
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button on:click class={`button-${type}`} aria-label={label}>
|
||||
<button
|
||||
on:click
|
||||
class={`${type} ${variant}`}
|
||||
class:disabled
|
||||
aria-label={label}
|
||||
type={functionType}
|
||||
{disabled}
|
||||
>
|
||||
<svelte:component this={icon} size={iconSize} color={iconColor} />
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
button {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
<style lang="scss">
|
||||
a,
|
||||
button {
|
||||
min-width: max-content;
|
||||
@@ -46,30 +51,55 @@
|
||||
transform 0.4s var(--bezier-one),
|
||||
filter 0.4s var(--bezier-one);
|
||||
user-select: none;
|
||||
padding: 16px 24px;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
filter: brightness(85%);
|
||||
}
|
||||
|
||||
.button-filled {
|
||||
&.disabled {
|
||||
filter: grayscale(100%);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.filled {
|
||||
background-color: var(--primary);
|
||||
color: var(--text-three);
|
||||
}
|
||||
.button-tonal {
|
||||
|
||||
&.tonal {
|
||||
background-color: var(--surface-four);
|
||||
}
|
||||
|
||||
.button-filled,
|
||||
.button-tonal {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
&.text {
|
||||
background-color: transparent;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
a:hover {
|
||||
filter: brightness(85%);
|
||||
&.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>
|
||||
|
||||
19
src/lib/components/Divider.svelte
Normal file
19
src/lib/components/Divider.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg aria-hidden="true" width="100%" height="8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<pattern id="a" width="91" height="8" patternUnits="userSpaceOnUse">
|
||||
<path
|
||||
d="M114 4c-5.067 4.667-10.133 4.667-15.2 0S88.667-.667 83.6 4 73.467 8.667 68.4 4 58.267-.667 53.2 4 43.067 8.667 38 4 27.867-.667 22.8 4 12.667 8.667 7.6 4-2.533-.667-7.6 4s-10.133 4.667-15.2 0S-32.933-.667-38 4s-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0-10.133-4.667-15.2 0-10.133 4.667-15.2 0"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</pattern>
|
||||
<rect width="100%" height="100%" fill="url(#a)" />
|
||||
</svg>
|
||||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
margin: 1.5rem 0;
|
||||
|
||||
path {
|
||||
stroke: var(--border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
82
src/lib/components/Gallery.svelte
Normal file
82
src/lib/components/Gallery.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import ImageModal from '$lib/components/ImageModal.svelte';
|
||||
|
||||
export let images: string[];
|
||||
export let columns: number = 3;
|
||||
export let gap: string = '1rem';
|
||||
|
||||
let selectedImage: { src: string; alt: string } | null = null;
|
||||
|
||||
function openModal(image: string, index: number) {
|
||||
selectedImage = {
|
||||
src: image,
|
||||
alt: `Gallery image ${index + 1}`
|
||||
};
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
selectedImage = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="gallery" style="--columns: {columns}; --gap: {gap}">
|
||||
{#each images as image, i}
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div class="image-container">
|
||||
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
||||
<img
|
||||
src={image}
|
||||
alt={`Gallery image ${i + 1}`}
|
||||
loading="lazy"
|
||||
on:click={() => openModal(image, i)}
|
||||
on:keydown={(e) => e.key === 'Enter' && openModal(image, i)}
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedImage}
|
||||
<ImageModal src={selectedImage.src} alt={selectedImage.alt} on:close={closeModal} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
gap: var(--gap);
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.gallery {
|
||||
--columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.gallery {
|
||||
--columns: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
src/lib/components/ImageModal.svelte
Normal file
76
src/lib/components/ImageModal.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let src: string;
|
||||
export let alt: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function closeModal() {
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') closeModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="modal-overlay" on:click={closeModal} transition:fade={{ duration: 175 }}>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="modal-content" on:click|stopPropagation transition:fade={{ duration: 175 }}>
|
||||
<button class="close-button" on:click={closeModal}>×</button>
|
||||
<img {src} {alt} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
right: -2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
line-height: 1;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
</style>
|
||||
65
src/lib/components/Input.svelte
Normal file
65
src/lib/components/Input.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
export let placeholder: string;
|
||||
export let required: boolean = false;
|
||||
export let value: any = '';
|
||||
export let type: string = 'text';
|
||||
|
||||
export let onenter: () => void = () => {};
|
||||
export let onexit: () => void = () => {};
|
||||
export let oninput: () => void = () => {};
|
||||
export let onkeydown: (event: KeyboardEvent) => void = (event) => {};
|
||||
|
||||
const set_type = (node: HTMLInputElement) => {
|
||||
node.type = type;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
id={placeholder.toLowerCase()}
|
||||
name={placeholder.toLowerCase()}
|
||||
{required}
|
||||
use:set_type
|
||||
on:focus={onenter}
|
||||
on:blur={onexit}
|
||||
on:input={oninput}
|
||||
on:keydown={onkeydown}
|
||||
bind:value
|
||||
/>
|
||||
<label for={placeholder.toLowerCase()}>{placeholder}</label>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.input-wrapper {
|
||||
width: auto;
|
||||
height: auto;
|
||||
position: relative;
|
||||
|
||||
label {
|
||||
position: absolute;
|
||||
top: 29%;
|
||||
left: 1rem;
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: var(--surface-six);
|
||||
pointer-events: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:focus + label,
|
||||
&:valid + label {
|
||||
top: -0.65rem;
|
||||
font-size: 0.85rem;
|
||||
background-color: var(--surface-seven);
|
||||
color: var(--text-one);
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
66
src/lib/stores.ts
Normal file
66
src/lib/stores.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { readable, writable } from 'svelte/store';
|
||||
import { is_logged_in, get_access_token } from './auth';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type AdminLoginInfo =
|
||||
| {
|
||||
logged_in: true;
|
||||
expires: number;
|
||||
logged_in_previously: boolean;
|
||||
}
|
||||
| {
|
||||
logged_in: false;
|
||||
expires: undefined;
|
||||
logged_in_previously: boolean;
|
||||
};
|
||||
|
||||
const admin_login_info = (): AdminLoginInfo => {
|
||||
if (is_logged_in())
|
||||
return {
|
||||
logged_in: true,
|
||||
expires: get_access_token()!.expires,
|
||||
logged_in_previously: !!get_access_token()?.token
|
||||
};
|
||||
else
|
||||
return {
|
||||
logged_in: false,
|
||||
expires: undefined,
|
||||
logged_in_previously: !!get_access_token()?.token
|
||||
};
|
||||
};
|
||||
|
||||
export const admin_login = readable<AdminLoginInfo>(admin_login_info(), (set) => {
|
||||
const checkLoginStatus = () => set(admin_login_info());
|
||||
|
||||
checkLoginStatus();
|
||||
|
||||
const interval = setInterval(checkLoginStatus, 100);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
export const read_announcements = writable<Set<number>>(new Set(), (set) => {
|
||||
if (!browser) return;
|
||||
|
||||
const key = 'read_announcements';
|
||||
const data = localStorage.getItem(key);
|
||||
const parsedArray = data ? JSON.parse(data) : [];
|
||||
const currentState = new Set(parsedArray);
|
||||
|
||||
const updateStoreState = () => {
|
||||
set(currentState);
|
||||
};
|
||||
|
||||
const handleLocalStorageUpdate = (e: StorageEvent) => {
|
||||
if (e.key === key) updateStoreState();
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleLocalStorageUpdate);
|
||||
updateStoreState();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleLocalStorageUpdate);
|
||||
localStorage.setItem(key, JSON.stringify(Array.from(currentState)));
|
||||
};
|
||||
});
|
||||
|
||||
export const passed_login_with_creds = writable(false); // will only change when the user INPUTS the credentials, not if the session is just valid
|
||||
@@ -1,3 +1,19 @@
|
||||
export type ResponseAnnouncement = {
|
||||
archived_at?: string;
|
||||
attachments?: string[];
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
content?: string;
|
||||
created_at: string;
|
||||
id: number;
|
||||
level?: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type Announcement = Omit<ResponseAnnouncement, 'id'>;
|
||||
|
||||
export type Tags = { name: string }[];
|
||||
|
||||
export interface Contributor {
|
||||
name: string;
|
||||
avatar_url: string;
|
||||
|
||||
189
src/routes/announcements/+page.svelte
Normal file
189
src/routes/announcements/+page.svelte
Normal file
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { derived, readable, type Readable } from 'svelte/store';
|
||||
import { building } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import { quintIn, quintOut } from 'svelte/easing';
|
||||
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
import AnnouncementCard from './AnnouncementCard.svelte';
|
||||
import { queries } from '$data/api';
|
||||
import TagsHost from './TagsHost.svelte';
|
||||
import Search from '$lib/components/Search.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import type { ResponseAnnouncement } from '$lib/types';
|
||||
import { admin_login } from '$lib/stores';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import moment from 'moment';
|
||||
import { debounce } from '$util/debounce';
|
||||
import createFilter from '$util/filter';
|
||||
|
||||
import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
|
||||
import Create from 'svelte-material-icons/Plus.svelte';
|
||||
|
||||
let searchParams: Readable<URLSearchParams>;
|
||||
|
||||
if (building) searchParams = readable(new URLSearchParams());
|
||||
else searchParams = derived(page, ($page) => $page.url.searchParams);
|
||||
|
||||
let searchTerm = $searchParams.get('s') || '';
|
||||
|
||||
$: query = createQuery(queries.announcements());
|
||||
$: tagsQuery = createQuery(queries.announcementTags());
|
||||
$: selectedTags = $searchParams.getAll('tag');
|
||||
|
||||
let expanded = false;
|
||||
|
||||
function filterAnnouncements(
|
||||
announcements: Iterable<ResponseAnnouncement>,
|
||||
search: string,
|
||||
selectedTags: string[]
|
||||
): ResponseAnnouncement[] {
|
||||
const announcementFilter = createFilter(Array.from(announcements), {
|
||||
searcherOptions: {
|
||||
keys: ['title', 'content']
|
||||
},
|
||||
additionalFilter: (announcement: ResponseAnnouncement, tags: string[]): boolean => {
|
||||
return (
|
||||
tags.length === 0 ||
|
||||
tags.some((tag) => announcement.tags && announcement.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return announcementFilter(selectedTags, search);
|
||||
}
|
||||
|
||||
// Make sure we don't have to filter the announcements after every key press
|
||||
let displayedTerm = '';
|
||||
const update = () => {
|
||||
displayedTerm = searchTerm;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = '/announcements';
|
||||
|
||||
if (searchTerm) url.searchParams.set('s', searchTerm);
|
||||
else url.searchParams.delete('s');
|
||||
};
|
||||
|
||||
onMount(update);
|
||||
</script>
|
||||
|
||||
<div class="search">
|
||||
<div class="search-contain">
|
||||
<!-- Must bind both variables: we get searchTerm from the text input, -->
|
||||
<div class="search-bar">
|
||||
<Search
|
||||
bind:searchTerm
|
||||
bind:displayedTerm
|
||||
title="Search for announcements"
|
||||
on:keyup={debounce(update)}
|
||||
/>
|
||||
</div>
|
||||
{#if $admin_login.logged_in}
|
||||
<Button type="filled" icon={Create} href="/announcements/create">Create</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<main class="wrapper" in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
|
||||
<Query query={tagsQuery} let:data>
|
||||
<TagsHost tags={data.tags} />
|
||||
</Query>
|
||||
|
||||
<Query {query} let:data>
|
||||
<div class="cards">
|
||||
{#each filterAnnouncements(data.announcements, displayedTerm, selectedTags) as announcement}
|
||||
{#if !announcement.archived_at || moment(announcement.archived_at).isAfter(moment())}
|
||||
{#key selectedTags || displayedTerm}
|
||||
<div in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
|
||||
<AnnouncementCard {announcement} />
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
class="expand-archived"
|
||||
aria-expanded={expanded}
|
||||
class:closed={!expanded}
|
||||
on:click={() => (expanded = !expanded)}
|
||||
on:keypress={() => (expanded = !expanded)}
|
||||
tabindex="0"
|
||||
>
|
||||
<h4>Archived announcements</h4>
|
||||
|
||||
<div id="arrow" style:transform={expanded ? 'rotate(0deg)' : 'rotate(-180deg)'}>
|
||||
<ChevronDown size="24px" color="var(--surface-six)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
<div
|
||||
class="cards"
|
||||
in:slide={{ easing: quintIn, duration: 250 }}
|
||||
out:slide={{ easing: quintOut, duration: 250 }}
|
||||
>
|
||||
{#each filterAnnouncements(data.announcements, displayedTerm, selectedTags) as announcement}
|
||||
{#if announcement.archived_at && moment(announcement.archived_at).isBefore(moment())}
|
||||
{#key selectedTags || displayedTerm}
|
||||
<AnnouncementCard {announcement} />
|
||||
{/key}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Query>
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
.expand-archived {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0rem 0.25rem;
|
||||
|
||||
#arrow {
|
||||
height: 1.5rem;
|
||||
transition: all 0.2s var(--bezier-one);
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
padding-top: 0.6rem;
|
||||
padding-bottom: 1.25rem;
|
||||
background-color: var(--surface-eight);
|
||||
|
||||
.search-contain {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-inline: auto;
|
||||
width: min(90%, 80rem);
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
padding: 16px 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
src/routes/announcements/AnnouncementBanner.svelte
Normal file
51
src/routes/announcements/AnnouncementBanner.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<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';
|
||||
|
||||
let latestUnreadAnnouncement: ResponseAnnouncement | undefined = undefined;
|
||||
|
||||
const query = createQuery(queries.announcements());
|
||||
|
||||
$: {
|
||||
if ($query.data?.announcements && $query.data.announcements.length > 0) {
|
||||
const announcement = $query.data.announcements[0];
|
||||
if (!$read_announcements.has(announcement.id)) {
|
||||
latestUnreadAnnouncement = announcement;
|
||||
} else {
|
||||
latestUnreadAnnouncement = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () =>
|
||||
latestUnreadAnnouncement && goto(`/announcements/${latestUnreadAnnouncement.id}`);
|
||||
|
||||
function handleClose() {
|
||||
if (latestUnreadAnnouncement && browser) {
|
||||
$read_announcements.add(latestUnreadAnnouncement.id);
|
||||
localStorage.setItem('read_announcements', JSON.stringify(Array.from($read_announcements)));
|
||||
latestUnreadAnnouncement = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getBannerLevel(level: number | undefined): 'info' | 'caution' {
|
||||
if (!level || level == 0) return 'info';
|
||||
return 'caution';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if latestUnreadAnnouncement}
|
||||
<Banner
|
||||
title={'We have an announcement'}
|
||||
description={`You can read more about "${latestUnreadAnnouncement.title}" in our latest post.`}
|
||||
level={getBannerLevel(latestUnreadAnnouncement.level)}
|
||||
buttonText="Read more"
|
||||
buttonOnClick={handleClick}
|
||||
onDismiss={handleClose}
|
||||
/>
|
||||
{/if}
|
||||
168
src/routes/announcements/AnnouncementCard.svelte
Normal file
168
src/routes/announcements/AnnouncementCard.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import moment from 'moment';
|
||||
import { onMount } from 'svelte';
|
||||
import type { ResponseAnnouncement } from '$lib/types';
|
||||
import NewHeader from './NewHeader.svelte';
|
||||
import { queries } from '$data/api';
|
||||
import { dev_log } from '$util/dev';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { read_announcements } from '$lib/stores';
|
||||
import TagsHost from './TagsHost.svelte';
|
||||
import Content from './[slug]/Content.svelte';
|
||||
import ToolTip from '$lib/components/ToolTip.svelte';
|
||||
import { relativeTime } from '$util/relativeTime';
|
||||
|
||||
import Archive from 'svelte-material-icons/ArchiveOutline.svelte';
|
||||
|
||||
export let announcement: ResponseAnnouncement;
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let isRead: boolean;
|
||||
|
||||
function prefetch() {
|
||||
const query = queries['announcementById'](announcement.id);
|
||||
dev_log('Prefetching', query);
|
||||
client.prefetchQuery(query);
|
||||
}
|
||||
|
||||
function setAnnouncementRead() {
|
||||
isRead = true;
|
||||
|
||||
$read_announcements.add(announcement.id);
|
||||
localStorage.setItem('read_announcements', JSON.stringify(Array.from($read_announcements)));
|
||||
}
|
||||
|
||||
function generateSlug(title: string) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
isRead = $read_announcements.has(announcement.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<a
|
||||
data-sveltekit-preload-data
|
||||
on:mouseenter={prefetch}
|
||||
href={`/announcements/${announcement.id}-${generateSlug(announcement.title)}`}
|
||||
on:click={setAnnouncementRead}
|
||||
>
|
||||
<div
|
||||
class="card"
|
||||
class:attachment={announcement.attachments && announcement.attachments.length > 0}
|
||||
>
|
||||
{#if isRead !== undefined && !isRead}
|
||||
<NewHeader />
|
||||
{/if}
|
||||
{#if announcement.attachments && announcement.attachments.length > 0}
|
||||
<img
|
||||
src={announcement.attachments[0]}
|
||||
class={isRead === undefined || isRead ? '' : 'no-border-radius'}
|
||||
alt="Banner"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
{/if}
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<h3>{announcement.title}</h3>
|
||||
<span>
|
||||
{relativeTime(announcement.created_at)}
|
||||
{#if announcement.archived_at && moment(announcement.archived_at).isBefore(moment())}
|
||||
<ToolTip
|
||||
content={`This announcement was archived ${relativeTime(announcement.archived_at)}`}
|
||||
>
|
||||
<Archive size="24" />
|
||||
</ToolTip>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="footer">
|
||||
{#if announcement.content}
|
||||
<Content content={announcement.content} clamp={true} />
|
||||
{/if}
|
||||
{#if announcement.tags && announcement.tags.length > 0}
|
||||
<hr />
|
||||
<TagsHost
|
||||
tags={announcement.tags.map((tag) => ({ name: tag }))}
|
||||
expandable={false}
|
||||
clickable={false}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.card {
|
||||
&.attachment {
|
||||
grid-row: span 2;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--surface-four);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
background-color: var(--surface-seven);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
|
||||
img {
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
border-radius: 12px 12px 0px 0px;
|
||||
|
||||
&.no-border-radius {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
height: 100%;
|
||||
padding: 12px 16px;
|
||||
|
||||
color: var(--text-four);
|
||||
|
||||
.header,
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
img {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
src/routes/announcements/NewHeader.svelte
Normal file
14
src/routes/announcements/NewHeader.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<span>NEW</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
text-align: center;
|
||||
background-color: var(--surface-four);
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
padding: 4px 0;
|
||||
border-radius: 12px 12px 0 0;
|
||||
pointer-events: none;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
</style>
|
||||
64
src/routes/announcements/TagChip.svelte
Normal file
64
src/routes/announcements/TagChip.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import Check from 'svelte-material-icons/Check.svelte';
|
||||
|
||||
export let tag: string;
|
||||
export let clickable: boolean = true;
|
||||
export let selected: boolean = false;
|
||||
export let onClick: (event?: MouseEvent) => void = () => {};
|
||||
|
||||
selected = clickable && selected;
|
||||
</script>
|
||||
|
||||
<button class:selected class:clickable on:click={clickable ? onClick : () => {}}>
|
||||
{#if selected && clickable}
|
||||
<div class="icon">
|
||||
<Check />
|
||||
</div>
|
||||
{/if}
|
||||
{tag}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
|
||||
background-color: var(--tertiary);
|
||||
color: var(--text-four);
|
||||
|
||||
letter-spacing: 0.02rem;
|
||||
font-size: 0.85rem;
|
||||
|
||||
user-select: none;
|
||||
transition: all 0.2s var(--bezier-one);
|
||||
|
||||
&.clickable {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&.selected {
|
||||
border-color: transparent;
|
||||
background-color: var(--tertiary);
|
||||
color: var(--primary);
|
||||
|
||||
.icon {
|
||||
display: inherit;
|
||||
margin-left: -6px;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-three);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
src/routes/announcements/TagsHost.svelte
Normal file
87
src/routes/announcements/TagsHost.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import TagChip from './TagChip.svelte';
|
||||
import { derived } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Tags } from '$lib/types';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import ChevronDown from 'svelte-material-icons/ChevronDown.svelte';
|
||||
|
||||
export let tags: Tags;
|
||||
export let expandable: boolean = false;
|
||||
export let clickable: boolean = true;
|
||||
|
||||
let showAllTags = expandable ? false : true;
|
||||
|
||||
const searchParams = derived(page, ($page) => $page.url.searchParams);
|
||||
|
||||
$: selectedTags = $searchParams.getAll('tag');
|
||||
|
||||
$: displayedTags = (() => {
|
||||
if (showAllTags) return tags.map((tag) => tag.name);
|
||||
if (selectedTags.length > 0) {
|
||||
return [tags[0]?.name, ...selectedTags.filter((tag) => tag !== tags[0]?.name)];
|
||||
}
|
||||
return tags.length > 0 ? [tags[0]?.name] : [];
|
||||
})();
|
||||
|
||||
const handleClick = (tag: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
|
||||
if (params.getAll('tag').includes(tag)) params.delete('tag', tag);
|
||||
else params.append('tag', tag);
|
||||
|
||||
url.search = params.toString();
|
||||
goto(url.pathname + url.search);
|
||||
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#each displayedTags as tag}
|
||||
<TagChip
|
||||
{tag}
|
||||
selected={$searchParams.getAll('tag').includes(tag)}
|
||||
onClick={() => handleClick(tag)}
|
||||
{clickable}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if expandable && tags.length > 1}
|
||||
<li class="button">
|
||||
<Button type="text" on:click={() => (showAllTags = !showAllTags)}>
|
||||
<div
|
||||
class="expand-arrow"
|
||||
style:transform={showAllTags ? 'rotate(90deg)' : 'rotate(-90deg)'}
|
||||
>
|
||||
<ChevronDown size="24px" color="var(--surface-six)" />
|
||||
</div>
|
||||
</Button>
|
||||
</li>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expand-arrow {
|
||||
transition: all 0.2s var(--bezier-one);
|
||||
user-select: none;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.rotate .expand-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
src/routes/announcements/[slug]/+layout.ts
Normal file
1
src/routes/announcements/[slug]/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = false;
|
||||
51
src/routes/announcements/[slug]/+page.svelte
Normal file
51
src/routes/announcements/[slug]/+page.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import Footer from '$layout/Footer/FooterHost.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { queries } from '$data/api';
|
||||
import { page } from '$app/stores';
|
||||
import Announcement from './Announcement.svelte';
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
|
||||
let announcementIdNumber: number | undefined = undefined;
|
||||
let isCreating: boolean = false;
|
||||
|
||||
$: {
|
||||
const lastSegment = $page.url.pathname.split('/').pop();
|
||||
isCreating = lastSegment === 'create';
|
||||
announcementIdNumber = isCreating ? undefined : Number(lastSegment.split('-')[0]);
|
||||
}
|
||||
|
||||
$: query = announcementIdNumber
|
||||
? createQuery(queries.announcementById(announcementIdNumber))
|
||||
: null;
|
||||
|
||||
$: announcement = $query?.data?.announcement || undefined;
|
||||
|
||||
$: slug = announcement?.title
|
||||
? announcement.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
: '';
|
||||
|
||||
$: {
|
||||
const slugPathname = `/announcements/${announcementIdNumber}-${slug}`;
|
||||
if (slug && $page.url.pathname !== slugPathname) {
|
||||
window.history.replaceState(null, '', slugPathname);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="wrapper" in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
|
||||
{#if query}
|
||||
<Query {query}>
|
||||
<Announcement {isCreating} {announcement} {announcementIdNumber} {query} />
|
||||
</Query>
|
||||
{:else}
|
||||
<Announcement {isCreating} {announcement} {announcementIdNumber} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
152
src/routes/announcements/[slug]/AdminButtons.svelte
Normal file
152
src/routes/announcements/[slug]/AdminButtons.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient, type CreateQueryResult } from '@tanstack/svelte-query';
|
||||
import { admin, queries } from '$data/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Dialogue from '$lib/components/Dialogue.svelte';
|
||||
import type { Announcement, ResponseAnnouncement } from '$lib/types';
|
||||
import moment from 'moment';
|
||||
import { isValidUrl } from '$util/isValidUrl';
|
||||
|
||||
import Delete from 'svelte-material-icons/DeleteOutline.svelte';
|
||||
import Edit from 'svelte-material-icons/PencilOutline.svelte';
|
||||
import Archive from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
|
||||
import Close from 'svelte-material-icons/Close.svelte';
|
||||
import Check from 'svelte-material-icons/Check.svelte';
|
||||
import Show from 'svelte-material-icons/EyeOutline.svelte';
|
||||
import Hide from 'svelte-material-icons/EyeOffOutline.svelte';
|
||||
import Unarchive from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
|
||||
|
||||
export let isEditing: boolean;
|
||||
export let isCreating: boolean;
|
||||
export let isPreviewing: boolean;
|
||||
export let archivedAtInput: string | undefined;
|
||||
export let showDeleteConfirm: boolean;
|
||||
export let announcementIdNumber: number | undefined;
|
||||
export let draftInputs: Announcement;
|
||||
export let query: CreateQueryResult<{ announcement: ResponseAnnouncement }, unknown> | undefined;
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const toggleArchived = () => {
|
||||
if (archivedAtInput) archivedAtInput = undefined;
|
||||
else archivedAtInput = moment().format('YYYY-MM-DDTHH:mm');
|
||||
};
|
||||
|
||||
const isValid = () => {
|
||||
const hasEmptyTitle = !draftInputs.title;
|
||||
const hasEmptyAttachments = draftInputs.attachments?.some((a) => !isValidUrl(a));
|
||||
|
||||
if (hasEmptyTitle || hasEmptyAttachments) {
|
||||
alert(
|
||||
`${[hasEmptyTitle && 'Title', hasEmptyAttachments && 'Attachments']
|
||||
.filter(Boolean)
|
||||
.join(' and ')} must be filled properly`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const sanitize = (draftInputs: Announcement) => {
|
||||
return {
|
||||
...draftInputs,
|
||||
content: draftInputs.content?.trim() || undefined,
|
||||
tags: draftInputs.tags && draftInputs.tags.length > 0 ? draftInputs.tags : undefined,
|
||||
archived_at: draftInputs.archived_at?.trim() || undefined,
|
||||
attachments:
|
||||
draftInputs.attachments && draftInputs.attachments?.length > 0
|
||||
? draftInputs.attachments
|
||||
: undefined,
|
||||
author: draftInputs.author?.trim() || undefined,
|
||||
level: draftInputs.level ?? undefined
|
||||
};
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!isValid()) return;
|
||||
|
||||
await admin.update_announcement(announcementIdNumber!, sanitize(draftInputs));
|
||||
await $query?.refetch();
|
||||
|
||||
isEditing = false;
|
||||
};
|
||||
|
||||
const createAnnouncement = async () => {
|
||||
if (!isValid()) return;
|
||||
|
||||
await admin.create_announcement(sanitize(draftInputs));
|
||||
await client.invalidateQueries(queries['announcements']());
|
||||
goto('/announcements', { invalidateAll: true });
|
||||
};
|
||||
|
||||
const deleteAnnouncement = async () => {
|
||||
admin.delete_announcement(announcementIdNumber!);
|
||||
await client.invalidateQueries(queries['announcements']());
|
||||
goto('/announcements', { invalidateAll: true });
|
||||
};
|
||||
|
||||
const handleUnload = (e: BeforeUnloadEvent) => {
|
||||
if (isEditing) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window on:beforeunload={handleUnload} />
|
||||
|
||||
<Dialogue bind:modalOpen={showDeleteConfirm}>
|
||||
<svelte:fragment slot="title">Confirm?</svelte:fragment>
|
||||
<svelte:fragment slot="description">Do you want to delete this announcement?</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button type="text" on:click={() => (showDeleteConfirm = !showDeleteConfirm)}>Cancel</Button>
|
||||
<Button type="filled" on:click={deleteAnnouncement}>OK</Button>
|
||||
</svelte:fragment>
|
||||
</Dialogue>
|
||||
|
||||
<div>
|
||||
{#if isEditing || isCreating}
|
||||
<Button
|
||||
icon={isPreviewing ? Hide : Show}
|
||||
iconColor="var(--secondary)"
|
||||
on:click={() => (isPreviewing = !isPreviewing)}
|
||||
/>
|
||||
<Button
|
||||
icon={archivedAtInput ? Unarchive : Archive}
|
||||
iconColor="var(--secondary)"
|
||||
on:click={toggleArchived}
|
||||
/>
|
||||
{#if isEditing}
|
||||
<Button
|
||||
icon={Close}
|
||||
iconColor="var(--secondary)"
|
||||
on:click={() => {
|
||||
isPreviewing = false;
|
||||
isEditing = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
icon={Check}
|
||||
iconColor="var(--secondary)"
|
||||
on:click={isEditing ? save : createAnnouncement}
|
||||
/>
|
||||
{:else}
|
||||
<Button
|
||||
icon={Delete}
|
||||
iconColor="var(--secondary)"
|
||||
on:click={() => (showDeleteConfirm = !showDeleteConfirm)}
|
||||
/>
|
||||
<Button icon={Edit} iconColor="var(--secondary)" on:click={() => (isEditing = !isEditing)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: var(--secondary);
|
||||
}
|
||||
</style>
|
||||
121
src/routes/announcements/[slug]/Announcement.svelte
Normal file
121
src/routes/announcements/[slug]/Announcement.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { admin_login } from '$lib/stores';
|
||||
import Title from './Title.svelte';
|
||||
import Divider from '$lib/components/Divider.svelte';
|
||||
import AdminButtons from './AdminButtons.svelte';
|
||||
import Author from './Author.svelte';
|
||||
import Date from './Date.svelte';
|
||||
import Content from './Content.svelte';
|
||||
import Attachments from './Attachments.svelte';
|
||||
import Tags from './Tags.svelte';
|
||||
import type { Announcement, ResponseAnnouncement } from '$lib/types';
|
||||
import type { CreateQueryResult } from '@tanstack/svelte-query';
|
||||
|
||||
export let isCreating: boolean;
|
||||
export let announcement: Announcement | undefined;
|
||||
export let announcementIdNumber: number | undefined;
|
||||
export let query: CreateQueryResult<{ announcement: ResponseAnnouncement }, unknown> | undefined =
|
||||
undefined;
|
||||
|
||||
let isPreviewing = false;
|
||||
let isEditing = false;
|
||||
let showDeleteConfirm = false;
|
||||
|
||||
const draftInputs: Announcement = {
|
||||
...announcement,
|
||||
id: undefined
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div>
|
||||
<Title
|
||||
{isCreating}
|
||||
{isEditing}
|
||||
{isPreviewing}
|
||||
title={announcement?.title}
|
||||
bind:titleInput={draftInputs.title}
|
||||
/>
|
||||
|
||||
<h4>
|
||||
<Date
|
||||
{isCreating}
|
||||
{isEditing}
|
||||
{isPreviewing}
|
||||
createdAt={announcement?.created_at}
|
||||
archivedAt={announcement?.archived_at}
|
||||
bind:archivedAtInput={draftInputs.archived_at}
|
||||
bind:createdAtInput={draftInputs.created_at}
|
||||
/>
|
||||
<Author
|
||||
{isCreating}
|
||||
{isEditing}
|
||||
{isPreviewing}
|
||||
author={announcement?.author}
|
||||
bind:authorInput={draftInputs.author}
|
||||
/>
|
||||
</h4>
|
||||
|
||||
<Tags {isCreating} {isEditing} {isPreviewing} bind:tagsInput={draftInputs.tags} />
|
||||
</div>
|
||||
|
||||
{#if $admin_login.logged_in}
|
||||
<AdminButtons
|
||||
{isCreating}
|
||||
bind:isEditing
|
||||
bind:isPreviewing
|
||||
bind:showDeleteConfirm
|
||||
bind:archivedAtInput={draftInputs.archived_at}
|
||||
{draftInputs}
|
||||
{announcementIdNumber}
|
||||
{query}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Content
|
||||
{isCreating}
|
||||
{isEditing}
|
||||
{isPreviewing}
|
||||
content={announcement?.content}
|
||||
bind:contentInput={draftInputs.content}
|
||||
/>
|
||||
|
||||
<Attachments
|
||||
{isCreating}
|
||||
{isEditing}
|
||||
{isPreviewing}
|
||||
attachments={announcement?.attachments}
|
||||
bind:attachmentsInput={draftInputs.attachments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.card {
|
||||
background-color: var(--surface-eight);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.card {
|
||||
background-color: initial;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
154
src/routes/announcements/[slug]/Attachments.svelte
Normal file
154
src/routes/announcements/[slug]/Attachments.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import Divider from '$lib/components/Divider.svelte';
|
||||
import Gallery from '$lib/components/Gallery.svelte';
|
||||
import { isValidUrl } from '$util/isValidUrl';
|
||||
import Create from 'svelte-material-icons/Plus.svelte';
|
||||
import Delete from 'svelte-material-icons/DeleteOutline.svelte';
|
||||
|
||||
export let isEditing: boolean;
|
||||
export let isCreating: boolean;
|
||||
export let isPreviewing: boolean;
|
||||
export let attachments: string[] | undefined;
|
||||
export let attachmentsInput: string[] | undefined;
|
||||
|
||||
let newAttachment: string | null = null;
|
||||
|
||||
const isValidAnnouncement = (attachment: string | null) => {
|
||||
return attachment && attachment && isValidUrl(attachment);
|
||||
};
|
||||
|
||||
const addAttachment = (attachment: string | null) => {
|
||||
if (!isValidAnnouncement(attachment)) return;
|
||||
|
||||
attachmentsInput = [...(attachmentsInput ?? []), attachment ? attachment : ''];
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
if (!attachmentsInput) return;
|
||||
|
||||
attachmentsInput = attachmentsInput.filter((_, i) => i !== index);
|
||||
};
|
||||
|
||||
$: displayAttachments = isPreviewing ? attachmentsInput : attachments;
|
||||
</script>
|
||||
|
||||
{#if (isEditing || isCreating) && !isPreviewing}
|
||||
<Divider />
|
||||
<div class="attachments-wrapper">
|
||||
{#if attachmentsInput}
|
||||
{#each attachmentsInput as attachment, index}
|
||||
<div class="attachments">
|
||||
<input
|
||||
bind:value={attachmentsInput[index]}
|
||||
class:empty={!attachment || (attachment && !isValidUrl(attachment))}
|
||||
placeholder="Attachment URL"
|
||||
/>
|
||||
<button
|
||||
class:last={index == attachmentsInput.length - 1}
|
||||
on:click={() => removeAttachment(index)}
|
||||
>
|
||||
<Delete size="24" color="var(--text-four)" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<span id="new-attachment">
|
||||
<input
|
||||
bind:value={newAttachment}
|
||||
class:empty={!isValidAnnouncement(newAttachment)}
|
||||
on:blur={() => {
|
||||
addAttachment(newAttachment);
|
||||
newAttachment = null;
|
||||
}}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Enter' && addAttachment(newAttachment)) newAttachment = null;
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<Button icon={Create} />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{:else if displayAttachments && displayAttachments?.length > 0}
|
||||
<Divider />
|
||||
<Gallery images={displayAttachments} />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding-right: 40px;
|
||||
letter-spacing: 0.02rem;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s var(--bezier-one);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
&.empty {
|
||||
border: 1px solid var(--red-one);
|
||||
}
|
||||
}
|
||||
|
||||
#new-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
width: 52px;
|
||||
border: 1px solid var(--border);
|
||||
padding-right: 0;
|
||||
|
||||
&:focus {
|
||||
width: 100%;
|
||||
+ span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
border: 1px solid var(--red-one);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 15.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
gap: 1rem;
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/routes/announcements/[slug]/Author.svelte
Normal file
38
src/routes/announcements/[slug]/Author.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
export let isEditing: boolean;
|
||||
export let isCreating: boolean;
|
||||
export let isPreviewing: boolean;
|
||||
export let author: string | undefined;
|
||||
export let authorInput: string | undefined;
|
||||
|
||||
$: displayAuthor = isPreviewing ? authorInput : author;
|
||||
</script>
|
||||
|
||||
{#if (isEditing || isCreating) && !isPreviewing}
|
||||
·
|
||||
<input
|
||||
bind:value={authorInput}
|
||||
class:empty={!authorInput?.trim()}
|
||||
placeholder="Enter author name"
|
||||
/>
|
||||
{:else if displayAuthor}
|
||||
·
|
||||
<span>
|
||||
{displayAuthor}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
input {
|
||||
&,
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02rem;
|
||||
}
|
||||
</style>
|
||||
112
src/routes/announcements/[slug]/Content.svelte
Normal file
112
src/routes/announcements/[slug]/Content.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
export let isEditing: boolean = false;
|
||||
export let isCreating: boolean = false;
|
||||
export let isPreviewing: boolean = false;
|
||||
export let content: string | undefined;
|
||||
export let contentInput: string | undefined = undefined;
|
||||
export let clamp: boolean = false;
|
||||
|
||||
$: displayContent = isPreviewing ? contentInput : content;
|
||||
</script>
|
||||
|
||||
{#if (isEditing || isCreating) && !isPreviewing}
|
||||
<textarea bind:value={contentInput} class:empty={!content?.trim()} placeholder="Enter content" />
|
||||
{:else if displayContent}
|
||||
<div class:clamp>
|
||||
{@html displayContent}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
textarea {
|
||||
&,
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02rem;
|
||||
}
|
||||
|
||||
div {
|
||||
color: var(--text-four);
|
||||
|
||||
&.clamp {
|
||||
display: -webkit-inline-box;
|
||||
line-clamp: 3;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
|
||||
:global(a) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(h1),
|
||||
:global(h2),
|
||||
:global(h3),
|
||||
:global(h4),
|
||||
:global(h5),
|
||||
:global(h6) {
|
||||
color: var(--secondary);
|
||||
line-height: 1.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline var(--secondary);
|
||||
color: var(--text-one);
|
||||
}
|
||||
}
|
||||
|
||||
:global(h2),
|
||||
:global(h3),
|
||||
:global(h4),
|
||||
:global(h5),
|
||||
:global(h6) {
|
||||
color: var(--secondary);
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
:global(h1) {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
:global(h2) {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
:global(h3) {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
:global(h4) {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
:global(h5) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
:global(h6) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
:global(li) {
|
||||
list-style-position: inside;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
src/routes/announcements/[slug]/Date.svelte
Normal file
67
src/routes/announcements/[slug]/Date.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { relativeTime } from '$util/relativeTime';
|
||||
import moment from 'moment';
|
||||
import ArrowRight from 'svelte-material-icons/ArrowRight.svelte';
|
||||
|
||||
export let isEditing: boolean;
|
||||
export let isCreating: boolean;
|
||||
export let isPreviewing: boolean;
|
||||
export let createdAt: string | undefined;
|
||||
export let createdAtInput: string | undefined;
|
||||
export let archivedAt: string | undefined;
|
||||
export let archivedAtInput: string | undefined;
|
||||
|
||||
if (createdAtInput) {
|
||||
createdAtInput = moment(createdAtInput).format('YYYY-MM-DDTHH:mm');
|
||||
} else {
|
||||
createdAtInput = moment().format('YYYY-MM-DDTHH:mm');
|
||||
}
|
||||
|
||||
$: displayCreatedAt = isPreviewing ? createdAtInput : createdAt;
|
||||
$: displayArchivedAt = isPreviewing ? archivedAtInput : archivedAt;
|
||||
</script>
|
||||
|
||||
{#if (isEditing || isCreating) && !isPreviewing}
|
||||
<span>
|
||||
<input type="datetime-local" max="9999-12-31T23:59" bind:value={createdAtInput} />
|
||||
{#if archivedAtInput}
|
||||
<ArrowRight size="24" />
|
||||
<input type="datetime-local" max="9999-12-31T23:59" bind:value={archivedAtInput} />
|
||||
{/if}
|
||||
</span>
|
||||
{:else if displayCreatedAt}
|
||||
<span>
|
||||
{relativeTime(displayCreatedAt)}
|
||||
|
||||
{#if displayArchivedAt}
|
||||
<ArrowRight size="24" />
|
||||
{relativeTime(displayArchivedAt)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
span {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
&,
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
filter: invert(88%) sepia(60%) saturate(4731%) hue-rotate(173deg) brightness(91%)
|
||||
contrast(111%);
|
||||
}
|
||||
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02rem;
|
||||
}
|
||||
</style>
|
||||
117
src/routes/announcements/[slug]/Tags.svelte
Normal file
117
src/routes/announcements/[slug]/Tags.svelte
Normal file
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import TagChip from '../TagChip.svelte';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { queries } from '$data/api';
|
||||
import Query from '$lib/components/Query.svelte';
|
||||
import Create from 'svelte-material-icons/Plus.svelte';
|
||||
|
||||
export let isEditing: boolean;
|
||||
export let isCreating: boolean;
|
||||
export let isPreviewing: boolean;
|
||||
export let tagsInput: string[];
|
||||
|
||||
$: query = createQuery(queries.announcementTags());
|
||||
$: tags = $query.data?.tags || [];
|
||||
|
||||
let newTag: string | null;
|
||||
|
||||
function handleTag(tag: string | null) {
|
||||
if (!tag) return;
|
||||
|
||||
if (tags.some((t) => t.name === tag)) {
|
||||
if (tagsInput?.includes(tag)) {
|
||||
tagsInput = tagsInput.filter((t) => t !== tag);
|
||||
|
||||
if (!$query.data?.tags.some((t) => t.name === tag)) {
|
||||
tags = tags.filter((t) => t.name !== tag);
|
||||
}
|
||||
} else {
|
||||
tagsInput = [...(tagsInput || []), tag];
|
||||
}
|
||||
} else {
|
||||
tags = [...tags, { name: tag }];
|
||||
tagsInput = [...(tagsInput || []), tag];
|
||||
}
|
||||
|
||||
newTag = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if (isEditing || isCreating) && !isPreviewing}
|
||||
<Query {query}>
|
||||
<div>
|
||||
{#each tags as tag}
|
||||
<TagChip
|
||||
tag={tag.name}
|
||||
selected={tagsInput && tagsInput.includes(tag.name)}
|
||||
onClick={() => handleTag(tag.name)}
|
||||
/>
|
||||
{/each}
|
||||
<div id="new-tag">
|
||||
<input
|
||||
bind:value={newTag}
|
||||
class:empty={!newTag}
|
||||
on:blur={() => handleTag(newTag)}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Enter') handleTag(newTag);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<Button icon={Create} iconColor="var(--text-four)" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Query>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#new-tag {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
|
||||
input {
|
||||
&,
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
&:focus {
|
||||
width: 100%;
|
||||
+ span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
span {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
background-color: var(--tertiary);
|
||||
color: var(--text-four);
|
||||
|
||||
letter-spacing: 0.02rem;
|
||||
font-size: 0.85rem;
|
||||
|
||||
transition: all 0.2s var(--bezier-one);
|
||||
}
|
||||
</style>
|
||||
41
src/routes/announcements/[slug]/Title.svelte
Normal file
41
src/routes/announcements/[slug]/Title.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
export let isEditing: boolean;
|
||||
export let isCreating: boolean;
|
||||
export let isPreviewing: boolean;
|
||||
export let title: string | undefined;
|
||||
export let titleInput: string;
|
||||
|
||||
$: displayTitle = isPreviewing ? titleInput : title;
|
||||
</script>
|
||||
|
||||
{#if (isEditing || isCreating) && !isPreviewing}
|
||||
<input bind:value={titleInput} class:empty={!titleInput?.trim()} placeholder="Enter title" />
|
||||
{:else if displayTitle}
|
||||
<h1>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
input {
|
||||
&,
|
||||
&:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
|
||||
color: var(--text-one);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 4rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
import { queries } from '$data/api';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
const query = createQuery(['contributors'], queries.contributors);
|
||||
const query = createQuery(queries.contributors());
|
||||
</script>
|
||||
|
||||
<Head
|
||||
|
||||
@@ -21,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;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
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;
|
||||
|
||||
@@ -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';
|
||||
@@ -20,8 +20,10 @@
|
||||
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;
|
||||
|
||||
@@ -38,49 +40,26 @@
|
||||
let mobilePackages = false;
|
||||
let showAllVersions = false;
|
||||
|
||||
function checkCompatibility(patch: Patch, pkg: string) {
|
||||
if (pkg === '') {
|
||||
return false;
|
||||
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
|
||||
);
|
||||
}
|
||||
return !!patch.compatiblePackages?.find((compat) => compat.name === pkg);
|
||||
}
|
||||
|
||||
function filter(patches: Patch[], pkg: string, search?: string): Patch[] {
|
||||
if (!search) {
|
||||
if (pkg) return patches.filter((patch) => checkCompatibility(patch, pkg));
|
||||
else return patches;
|
||||
}
|
||||
|
||||
if (!searcher) {
|
||||
searcher = new Fuse(patches, {
|
||||
keys: ['name', 'description', 'compatiblePackages.name', 'compatiblePackages.versions'],
|
||||
shouldSort: true,
|
||||
threshold: 0.3
|
||||
});
|
||||
}
|
||||
|
||||
const result = searcher
|
||||
.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;
|
||||
|
||||
@@ -183,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 }}>
|
||||
|
||||
7
src/util/debounce.ts
Normal file
7
src/util/debounce.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const debounce = <T extends any[]>(f: (...args: T) => void) => {
|
||||
let timeout: number;
|
||||
return (...args: T) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => f(...args), 350);
|
||||
};
|
||||
};
|
||||
34
src/util/filter.ts
Normal file
34
src/util/filter.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
type SearcherOptions<T> = {
|
||||
keys: string[];
|
||||
shouldSort?: boolean;
|
||||
threshold?: number;
|
||||
};
|
||||
|
||||
type FilterOptions<T, C> = {
|
||||
searcherOptions: SearcherOptions<T>;
|
||||
additionalFilter?: (item: T, context: C) => boolean;
|
||||
};
|
||||
|
||||
function createFilter<T, C>(items: T[], options: FilterOptions<T, C>) {
|
||||
const { searcherOptions, additionalFilter } = options;
|
||||
|
||||
const searcher = new Fuse(items, {
|
||||
keys: searcherOptions.keys,
|
||||
shouldSort: searcherOptions.shouldSort ?? true,
|
||||
threshold: searcherOptions.threshold ?? 0.3
|
||||
});
|
||||
|
||||
return (context: C, search?: string): T[] => {
|
||||
if (!search) {
|
||||
return additionalFilter ? items.filter((item) => additionalFilter(item, context)) : items;
|
||||
}
|
||||
|
||||
const results = searcher.search(search).map(({ item }) => item);
|
||||
|
||||
return additionalFilter ? results.filter((item) => additionalFilter(item, context)) : results;
|
||||
};
|
||||
}
|
||||
|
||||
export default createFilter;
|
||||
3
src/util/fromNow.ts
Normal file
3
src/util/fromNow.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import moment from 'moment';
|
||||
|
||||
export const fromNow = (timestamp: number) => moment(timestamp).fromNow(true);
|
||||
8
src/util/isValidUrl.ts
Normal file
8
src/util/isValidUrl.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const isValidUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
7
src/util/relativeTime.ts
Normal file
7
src/util/relativeTime.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import moment from 'moment';
|
||||
|
||||
export const relativeTime = (date: string, withinDays: number = 7) => {
|
||||
return moment().diff(moment(date), 'days') <= withinDays
|
||||
? moment(date).fromNow()
|
||||
: moment(date).format('on MMMM D, YYYY [at] h:mm A');
|
||||
};
|
||||
Reference in New Issue
Block a user