Compare commits
64 Commits
Author | SHA1 | Date |
---|---|---|
DrMint | d293565e6f | |
DrMint | 4460397daf | |
DrMint | 7834d0fe7d | |
DrMint | 403f7e087d | |
DrMint | 1a6e0b315b | |
DrMint | e26ac32903 | |
DrMint | 1c85884f70 | |
DrMint | 99e0fd6bc7 | |
DrMint | e6ac2d8651 | |
DrMint | 6b797a42f8 | |
DrMint | 90266abc91 | |
DrMint | 7dd91f5847 | |
DrMint | f1b37a31a9 | |
DrMint | 7229b78578 | |
DrMint | c311c0921d | |
DrMint | ba6de7244a | |
DrMint | 9679bc77fd | |
DrMint | 7594a8353f | |
DrMint | b558ecbfa1 | |
DrMint | 989854957d | |
DrMint | c707733bbc | |
DrMint | 0d33354b7f | |
DrMint | acc9be8bad | |
DrMint | 0cf71b4d95 | |
DrMint | f94d6b24ab | |
DrMint | 62a89706ec | |
DrMint | e7eb324cb1 | |
DrMint | a0e7d97967 | |
DrMint | 804cd4cbb2 | |
DrMint | 585ce20cd1 | |
DrMint | 1a55350110 | |
DrMint | cc24134f37 | |
DrMint | 94c171d6bf | |
DrMint | 465e0612ff | |
DrMint | f6e791a7ac | |
DrMint | d3ebc15b77 | |
DrMint | 5f70ca4b92 | |
DrMint | 591ea6b976 | |
DrMint | efb0f03be8 | |
DrMint | 2a505ebd7a | |
DrMint | a9e4e91e8d | |
DrMint | c9b6d11c9b | |
DrMint | 246cdb4af0 | |
DrMint | cbbf0e5e3b | |
DrMint | de9ad38835 | |
DrMint | cc79e51841 | |
DrMint | 6eb64b48a2 | |
DrMint | 2cacccae86 | |
DrMint | d9ef48d811 | |
DrMint | e89a125052 | |
DrMint | 8142d69bb7 | |
DrMint | e854d88d89 | |
DrMint | 66ac5bd519 | |
DrMint | 3e4d1bff26 | |
DrMint | 7f1f4a7f89 | |
DrMint | e9f576245d | |
DrMint | f7d994dcb8 | |
DrMint | 9105b04032 | |
DrMint | 4ac350d7f5 | |
DrMint | 47668e3b29 | |
DrMint | 73dfc452cc | |
DrMint | db6b331380 | |
DrMint | 3b3b6951fe | |
DrMint | 4f23b02097 |
11
.env.example
11
.env.example
|
@ -8,8 +8,19 @@ PAYLOAD_USER=myemail@domain.com
|
|||
PAYLOAD_PASSWORD=somepassword123
|
||||
WEB_HOOK_TOKEN=webhookd5e6ea45ef4e66eaa151612bdcb599df
|
||||
|
||||
## CACHING
|
||||
DATA_CACHING=false
|
||||
DATA_PRECACHING=false
|
||||
PAGE_CACHING=false
|
||||
PAGE_PRECACHING=false
|
||||
CACHE_CONTROL=false
|
||||
|
||||
## OPEN EXCHANGE RATE
|
||||
OER_APP_ID=oerappid5e6ea45ef4e66eaa151612bdcb599df
|
||||
|
||||
## MEILI
|
||||
MEILISEARCH_URL=https://meilisearch.domain.com
|
||||
MEILISEARCH_MASTER_KEY=some_api_keyqs23d1qs6d54qs897qs3
|
||||
|
||||
## ANALYTICS
|
||||
ANALYTICS_URL=http://analytics.domain.com
|
|
@ -19,3 +19,6 @@ pnpm-debug.log*
|
|||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# caching
|
||||
.cache
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "src/shared"]
|
||||
path = src/shared
|
||||
url = https://github.com/Accords-Library/shared-library.git
|
|
@ -1,2 +1,2 @@
|
|||
src/shared/*
|
||||
!src/shared/analytics
|
||||
dist
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "antfu.iconify"],
|
||||
"recommendations": ["astro-build.astro-vscode", "antfu.iconify", "esbenp.prettier-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
{
|
||||
"editor.rulers": [100],
|
||||
"editor.tabSize": 2,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"package.json": ".git*, package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, .ncurc.*, .nvmrc, *.config.cjs, *.config.js, *.config.ts, *config.json, .*ignore",
|
||||
".env": ".env.*",
|
||||
"README.md": "*.md"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
## DOD
|
||||
|
||||
- Jugment chapter 1
|
||||
- Magnitude negative
|
||||
- Missing tags
|
||||
- Soundtrack vol1
|
||||
- Missing scans
|
||||
- booklet?
|
||||
- Soundtrack vol2
|
||||
- Missing cd
|
||||
- Missing scans
|
||||
- booklet?
|
||||
- https://v3.accords-library.com/en/pages/song-of-fourteen-years
|
||||
- missing source to https://web.archive.org/web/20151104092843/http://kho-dazat.tumblr.com/post/73835598260/you-are-the-light-the-light-that-illuminates-my
|
||||
- Character novellas, The content originate -> The content originates
|
||||
- https://v3.accords-library.com/en/folders/drakengard-magnitude-negative add previous/follow-up links to chapters
|
||||
|
||||
- Missing parent collectible
|
||||
|
||||
- https://v3.accords-library.com/en/folders/drakengard-promotional-videos
|
||||
- https://v3.accords-library.com/en/videos/663354cb12807e2ec1b33924
|
||||
|
||||
- Missing collectibles
|
||||
|
||||
- Games
|
||||
- https://accords-library.com/library/dod-java
|
||||
- https://accords-library.com/library/dod-demo
|
||||
|
||||
- Missing pages
|
||||
- https://accords-library.com/contents/dod-history
|
||||
|
||||
## DOD1.3
|
||||
|
||||
- Drag-on Dragoon - Fatal Crimson
|
||||
|
||||
- Missing footnotes
|
||||
- https://v3.accords-library.com/en/pages/dod1-3-a-2040-battle-with-the-empire
|
||||
- https://v3.accords-library.com/en/pages/dod1-3-a-4010-the-truly-diseased
|
||||
- https://v3.accords-library.com/en/pages/dod1-3-a-4030-little-sister
|
||||
|
||||
## DOD 2
|
||||
|
||||
- Missing pages
|
||||
|
||||
- https://accords-library.com/contents/folder/dod2-promotional-materials
|
||||
- https://accords-library.com/contents/dod-history
|
||||
|
||||
- Missing collectibles
|
||||
|
||||
- Soundtrack
|
||||
- https://accords-library.com/library/hitori-mika-nakashima
|
||||
- https://accords-library.com/library/sirius-eir-aoi
|
||||
|
||||
- Missing content
|
||||
|
||||
- https://v3.accords-library.com/en/collectibles/dod2-web-material-disc
|
||||
|
||||
- Missing footnotes
|
||||
|
||||
- https://v3.accords-library.com/en/pages/looking-back-2
|
||||
|
||||
- Missing scans
|
||||
- https://accords-library.com/library/dod2-memory-of-blood
|
||||
|
||||
## DOD3
|
||||
|
||||
- Missing collectibles
|
||||
|
||||
- https://accords-library.com/library/utahime-five
|
||||
- https://accords-library.com/library/dod3-official-score-book
|
||||
- Soundtrack
|
||||
- https://accords-library.com/library/this-silence-is-mine
|
||||
- https://accords-library.com/library/dod3-original-soundtrack
|
||||
|
||||
- Missing content
|
||||
|
||||
- https://v3.accords-library.com/en/collectibles/dod-chips-music
|
||||
- https://v3.accords-library.com/en/collectibles/dod-history-films
|
||||
- https://accords-library.com/library/dod3-collector-edition
|
||||
|
||||
- Missing pages
|
||||
|
||||
- https://accords-library.com/contents/prophecy
|
||||
- https://accords-library.com/contents/dod3-complete-guide-epilogue
|
||||
- https://accords-library.com/contents/dod-history
|
||||
|
||||
- Missing parent page
|
||||
|
||||
- Novellas
|
||||
- https://accords-library.com/contents/folder/dod3-dlc-short-stories
|
||||
|
||||
- Missing scans
|
||||
|
||||
- https://v3.accords-library.com/en/collectibles/novel-prelude
|
||||
- Wrong rotation on pages for https://v3.accords-library.com/en/collectibles/visual-artbook/scans
|
||||
- https://v3.accords-library.com/en/collectibles/dod10-dlc-code
|
||||
|
||||
- Check release data for https://v3.accords-library.com/en/collectibles/dod10th-anniversary-box and its subitems
|
||||
|
||||
- Missing footnotes
|
||||
- https://v3.accords-library.com/en/pages/super-space-time-character-exchange-roundtable-in-shinjuku
|
||||
- *Like mother, like daughter. I think that's just it… Why I must protect Yonah for the rest of my life! So we can always, always be together… *The proverb here in Japanese directly translates to “You can’t fight blood.”, a likely prod at Furiae’s lust for Caim.
|
||||
|
||||
# NieR
|
||||
|
||||
- Novellas
|
||||
|
||||
- Add credits to wikia or SolemCube
|
||||
- Add more tags
|
||||
|
||||
- https://v3.accords-library.com/en/collectibles/grimoire-nier-revised-edition
|
47
README.md
47
README.md
|
@ -1,24 +1,28 @@
|
|||
# Accord's Library
|
||||
|
||||
Accord's Library v3.0 (shorten to AL3.0)
|
||||
|
||||
## Tech overview
|
||||
|
||||
- Client-side framework: None
|
||||
- Web framework / server: [Astro](https://astro.build)
|
||||
- Web framework / SSR: [Astro](https://astro.build)
|
||||
- Content management system: [Payload](https://payloadcms.com)
|
||||
- Database: MongoDB
|
||||
|
||||
## Core
|
||||
|
||||
- https://resilientwebdesign.com/
|
||||
|
||||
Accord's Library v3.0 (shorten to AL3.0) follows the Metamodernist Web model described by Frédéric Bonnet in his article [From Classicism to Metamodernism — A Short History of Web Development](https://dev.to/fredericbonnet/the-third-age-of-web-development-kgj#the-metamodernist-period).
|
||||
## Core principles
|
||||
|
||||
- Embrace web standards instead of reinventing the wheel
|
||||
- [Progressive enhancement](https://en.wikipedia.org/wiki/Progressive_enhancement): SSG or SSR for noscript clients. SPA-like enhancements such as partial page updates and view transitions for clients with JS support.
|
||||
- [Progressive enhancement](https://en.wikipedia.org/wiki/Progressive_enhancement). Ensure all users can access and use AL3.0's core functionalities.
|
||||
- End-user preferences are respected.
|
||||
- Mimimal dependencies. Dependencies can be self-hosted or loaded directly from CDNs instead of being bundled up.
|
||||
- Accessible, fast, lightweight, substainable
|
||||
- Complexity is moved away from client devices
|
||||
|
||||
Read more:
|
||||
|
||||
- [Resilient web design](https://resilientwebdesign.com)
|
||||
- [From Classicism to Metamodernism — A Short History of Web Development](https://dev.to/fredericbonnet/the-third-age-of-web-development-kgj#the-metamodernist-period)
|
||||
|
||||
## Focal points
|
||||
|
||||
- Progressive enhancement / Graceful degradation
|
||||
|
@ -68,18 +72,28 @@ Accord's Library v3.0 (shorten to AL3.0) follows the Metamodernist Web model des
|
|||
- Lazy loaded
|
||||
- Space reservation to reduce Cumulative Layout Shift
|
||||
- Use of efficient formats (mostly WebP) and meaningful quality settings
|
||||
- Server side rendered (both good and bad for speed)
|
||||
- Server side rendered
|
||||
- Reduced data transfer
|
||||
- Reduced client-side complexity
|
||||
- Would require edge computing to reduce latency
|
||||
- Astro built-in View transitions and client-side navigation
|
||||
- Some data caching between the web server and CMS (to be improved)
|
||||
- Advanced caching
|
||||
- Data caching
|
||||
- Reponses from the CMS are cached to speed up response time.
|
||||
- When there is a change on the CMS, the cache is alerted through webhooks.
|
||||
- The impacted responses are then invalidated, fetched again, and cached
|
||||
- All possible reponses from the CMS are precached when the server loads.
|
||||
- The cache is also saved on disk after any change, and is loading at startup if available.
|
||||
- Page caching
|
||||
- The pages themselves are cached to further speed up response time.
|
||||
- When responses in the Data caching are invalidated, cached pages that depend on those reponses are also invalidated. They are then regenerated and cached again.
|
||||
- Similarly to the data cache, all pages are precached when the server loads and the cache is saved on disk and loaded at startup.
|
||||
- The server use the `Last-Modified` header and serve a `304 Not Modified` response when appropriate.
|
||||
- The pages themselves use the `Cache-Control` header to allow local caching on the visitors' web browsers.
|
||||
|
||||
- SEO
|
||||
|
||||
- Good defaults for the metadata and OpenGraph properties
|
||||
- Each page can provide a custom title, description, thumbnail, video, audio to be used
|
||||
- Each language variants are indexes seperately.
|
||||
- Each language variants are indexed seperately.
|
||||
|
||||
- Complexity
|
||||
- The complexity should be moved away from public-facing parts of the codebase
|
||||
|
@ -92,7 +106,6 @@ Accord's Library v3.0 (shorten to AL3.0) follows the Metamodernist Web model des
|
|||
- Handle user interactions
|
||||
- On the client device, there should be minimal complexity
|
||||
- Handle responsiveness
|
||||
- Handle view transitions (if JS is available)
|
||||
- Use of web standards: let the browser handle most of the client-side complexity
|
||||
|
||||
## Enhancement provided with JavaScript
|
||||
|
@ -105,6 +118,7 @@ Accord's Library v3.0 (shorten to AL3.0) follows the Metamodernist Web model des
|
|||
|
||||
- Smooth scrolling when using anchor links
|
||||
- On image pages (scans, gallery, image files), allow the user to navigate to the previous or next image using keyboard arrows.
|
||||
- On the search page, allow the user to navigate to the previous or next page using keyboard arrows.
|
||||
|
||||
- On media pages (scans, images, audios, videos), provide a download button. This way, the user doesn't have to right-click -> "save media as..."
|
||||
|
||||
|
@ -128,13 +142,6 @@ A dotted texture is displayed on the page background. It uses `background-blend-
|
|||
- Replacing the image with a non-transparent image where the blending is baked-in
|
||||
- Check if there are ways to make the blending work on iOS
|
||||
|
||||
### Parallax effect
|
||||
|
||||
A parallax effect is applied on the webpages' background image. This effect can be a bit demanding, it is disabled on mobiles and tablets to lessen the impact. Other alternatives could include:
|
||||
|
||||
- Removing the effet entirely
|
||||
- Moving away from JavaScript and using CSS parallax tricks (transform 3D, sticky)
|
||||
|
||||
## CSS Utility classes
|
||||
|
||||
- `.dark-theme`: force dark theming to the element and its children.
|
||||
|
|
25
TODO.md
25
TODO.md
|
@ -2,23 +2,33 @@
|
|||
|
||||
## Ongoing
|
||||
|
||||
- [Bugs] It's possible to save a page with empty Line or Cue
|
||||
- [Bugs] Keziah reported some lag spikes when scrolling on the home page (Firefox on Windows)
|
||||
- [Feat] [Analytics] Add analytics
|
||||
- [Bugs] [Tooltips] Tooltip in under next element (example in timeline)
|
||||
- [Bugs] [Language override] Maso actor is not focusable with keyboard nav
|
||||
- [Bugs] [KeyboardNav]:
|
||||
- Maso actor is not focusable with keyboard nav
|
||||
- Parent pages not focusable
|
||||
- Search button is double-focusable (once the link, and one the button)
|
||||
|
||||
## Short term
|
||||
|
||||
- [Feat] 404, 500 pages
|
||||
- [Feat] Add links to all the timeline image and document on Timeline page
|
||||
- [Bugs] Make sure uploads name are slug-like and with an extension.
|
||||
- [Bugs] Nyupun can't upload subtitles files
|
||||
- [Bugs] https://v3.accords-library.com/en/collectibles/dod-original-soundtrack/scans obi is way too big
|
||||
- [Feat] [RichTextContent] Handle relationship
|
||||
- [Feat] [Timeline] Improve layout/spacing on mobile
|
||||
- [Feat] Display if a content has a source language
|
||||
- [Feat] [JSLess] Display if a content is available in more than one language
|
||||
- [Bugs] Number of audio players seems limited (on Chrome and Firefox)
|
||||
- [Feat] [RichTextContent] Add autolink block support
|
||||
|
||||
## Mid term
|
||||
|
||||
- [Feat] Improve page load speed by using
|
||||
- streaming https://docs.astro.build/en/recipes/streaming-improve-page-performance/
|
||||
- https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
|
||||
- [Feat] History replace instead of push when browsing scans and gallery
|
||||
- [Feat] Use subgrid to align the generic previews
|
||||
- [Bugs] [Timeline] Error if collectible not published?
|
||||
- [Feat] [Timeline] display source language
|
||||
- [Feat] [Timeline] Add details button in footer with credits + last updated / created
|
||||
|
@ -26,16 +36,20 @@
|
|||
- [Feat] [Videos] Display platform info + channel page
|
||||
- [Feat] [JSLess] Provide JS-less alternative for timeline card footers
|
||||
- [Feat] [JSLess] Provide JS-less alternative for parent pages
|
||||
- [Feat] Add a sitemap https://straffesites.com/en/blog/localized-sitemap-astro-storyblok
|
||||
|
||||
## Long term
|
||||
|
||||
- [Feat] Invalidate Back/Forward Cache when changing language/theme/currency
|
||||
- [Feat] Hovering on a preview card could give a more detailed summary/preview (with all attributes)
|
||||
- [Feat] Explore posibilities for View Transitions
|
||||
- [Feat] Revemp theme system using light-dark https://caniuse.com/mdn-css_types_color_light-dark
|
||||
- [Feat] Add reduce motion to element that zoom when hovering
|
||||
- [Bugs] [iOS] Video doesn't seem to start
|
||||
- [Feat] [Folders] Provide a list view, and a list/grid toggle
|
||||
- [Feat] [Payload] Endpoints should provide a simple text-based version of the content (for OG purposes)
|
||||
- [Feat] [WebManifest] Add shortcuts https://web.dev/patterns/web-apps/shortcuts
|
||||
- [Feat] [PWA] Rich install UI https://web.dev/patterns/web-apps/richer-install-ui/
|
||||
- [Feat] More data caching between the CMS and Astro
|
||||
- [Feat] [Folders] Support for nameless section
|
||||
- [Feat] [Scripts] Can't run the scripts using node (ts-node?)
|
||||
- [Feat] [Scans] Order of cover/dustjacket/obi should be based on the book's page order.
|
||||
|
@ -62,3 +76,4 @@
|
|||
|
||||
- [Feat] Static HTML site export for archival
|
||||
- [Feat] Secret Terminal mode
|
||||
- [Feat] Add RSS
|
||||
|
|
|
@ -11,10 +11,22 @@ export default defineConfig({
|
|||
srcDir: "./src",
|
||||
publicDir: "./public",
|
||||
outDir: "./dist",
|
||||
trailingSlash: "never",
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
integrations: [
|
||||
// We can't just call a function on startup because of some Vite's BS...
|
||||
// So instead I'm exposing some endpoint that only does something the first time it's called.
|
||||
{
|
||||
name: "on-server-start",
|
||||
hooks: {
|
||||
"astro:config:done": () => {
|
||||
console.log("Running on startup script in 10s...");
|
||||
setTimeout(() => fetch(`http://${ASTRO_HOST}:${ASTRO_PORT}/en/api/on-startup`), 10_000);
|
||||
},
|
||||
},
|
||||
},
|
||||
astroMetaTags(),
|
||||
icon({
|
||||
include: {
|
||||
|
@ -23,7 +35,7 @@ export default defineConfig({
|
|||
}),
|
||||
],
|
||||
prefetch: false,
|
||||
// devToolbar: { enabled: false },
|
||||
devToolbar: { enabled: false },
|
||||
server: {
|
||||
port: parseInt(ASTRO_PORT ?? "4321"),
|
||||
host: ASTRO_HOST ?? true,
|
||||
|
|
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "v3.accords-library.com",
|
||||
"version": "3.0.0-beta.4",
|
||||
"version": "3.0.0-beta.9",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
|
@ -8,33 +8,33 @@
|
|||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"upgrade": "ncu",
|
||||
"script:download-payload-sdk": "npm run scripts/download-payload-sdk.ts",
|
||||
"script:download-currencies": "npm run scripts/download-currencies.ts",
|
||||
"fetch-submodules": "cd src/shared && git pull && cd ../..",
|
||||
"script:download-currencies": "node --env-file=.env scripts/download-currencies.mjs",
|
||||
"script:download-wording-keys": "npm run scripts/download-wording-keys.ts",
|
||||
"prettier": "prettier --write --list-different --plugin=prettier-plugin-astro .",
|
||||
"precommit": "npm run script:download-currencies && npm run script:download-payload-sdk && npm run script:download-wording-keys && npm run prettier && npm run astro check"
|
||||
"precommit": "npm run fetch-submodules && npm run script:download-currencies && npm run script:download-wording-keys && npm run prettier && npm run astro check"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=10.0.0",
|
||||
"node": ">=19.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.7.0",
|
||||
"@astrojs/node": "^8.3.0",
|
||||
"accept-language": "^3.0.18",
|
||||
"astro": "4.10.2",
|
||||
"@astrojs/check": "^0.9.2",
|
||||
"@astrojs/node": "^8.3.3",
|
||||
"accept-language": "^3.0.20",
|
||||
"astro": "4.14.0",
|
||||
"astro-icon": "^1.1.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"ua-parser-js": "^1.0.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/material-symbols": "^1.1.82",
|
||||
"@iconify-json/material-symbols": "^1.1.87",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"astro-meta-tags": "^0.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss-preset-env": "^9.5.14",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-astro": "^0.14.0",
|
||||
"typescript": "^5.4.5"
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss-preset-env": "^10.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 400 KiB |
Binary file not shown.
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
|
@ -1,2 +1,4 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
||||
Disallow: /
|
||||
Disallow: /*/api/
|
||||
Disallow: /*/dev/
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
rm -r dist
|
||||
npm ci
|
||||
npm run precommit
|
||||
npm run build
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { writeFileSync, readFileSync, existsSync } from "fs";
|
||||
// @ts-check
|
||||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
|
||||
|
||||
const OPEN_EXCHANGE_FOLDER = `${process.cwd()}/src/shared/openExchange`;
|
||||
const OPEN_EXCHANGE_FOLDER = `${process.cwd()}/src/dist/openExchange`;
|
||||
const RATE_JSON_PATH = `${OPEN_EXCHANGE_FOLDER}/rates.json`;
|
||||
const CURRENCIES_JSON_PATH = `${OPEN_EXCHANGE_FOLDER}/currencies.json`;
|
||||
const ONE_DAY_IN_MS = 1_000 * 60 * 60 * 24;
|
||||
|
@ -17,15 +18,21 @@ if (existsSync(RATE_JSON_PATH)) {
|
|||
}
|
||||
}
|
||||
|
||||
const ratesUrl = `https://openexchangerates.org/api/latest.json?app_id=${
|
||||
import.meta.env.OER_APP_ID
|
||||
}`;
|
||||
if (!process.env.OER_APP_ID) {
|
||||
throw new Error("Missing OER_APP_ID env variable");
|
||||
}
|
||||
|
||||
const ratesUrl = `https://openexchangerates.org/api/latest.json?app_id=${process.env.OER_APP_ID}`;
|
||||
const currenciesUrl = `https://openexchangerates.org/api/currencies.json?app_id=${
|
||||
import.meta.env.OER_APP_ID
|
||||
process.env.OER_APP_ID
|
||||
}`;
|
||||
|
||||
const rates = await fetch(ratesUrl);
|
||||
|
||||
if (!existsSync(OPEN_EXCHANGE_FOLDER)) {
|
||||
mkdirSync(OPEN_EXCHANGE_FOLDER, { recursive: true });
|
||||
}
|
||||
|
||||
if (rates.ok) {
|
||||
writeFileSync(RATE_JSON_PATH, await rates.text(), {
|
||||
encoding: "utf-8",
|
|
@ -1,14 +0,0 @@
|
|||
import { writeFileSync } from "fs";
|
||||
|
||||
const PAYLOAD_FOLDER = `${process.cwd()}/src/shared/payload`;
|
||||
|
||||
const sdk = await fetch(`${import.meta.env.PAYLOAD_API_URL}/sdk`);
|
||||
|
||||
if (!sdk.ok) {
|
||||
console.error("Failed to get the sdk", sdk.status, sdk.statusText);
|
||||
} else {
|
||||
const sdkFile = await sdk.text();
|
||||
writeFileSync(`${PAYLOAD_FOLDER}/payload-sdk.ts`, sdkFile, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
|
@ -1,10 +1,16 @@
|
|||
import { writeFileSync } from "fs";
|
||||
import { payload } from "src/utils/payload";
|
||||
import { PayloadSDK } from "src/shared/payload/sdk";
|
||||
|
||||
const TRANSLATION_FOLDER = `${process.cwd()}/src/i18n`;
|
||||
|
||||
const payload = new PayloadSDK(
|
||||
import.meta.env.PAYLOAD_API_URL,
|
||||
import.meta.env.PAYLOAD_USER,
|
||||
import.meta.env.PAYLOAD_PASSWORD
|
||||
);
|
||||
|
||||
try {
|
||||
const wordings = await payload.getWordings();
|
||||
const { data: wordings } = await payload.getWordings();
|
||||
const keys = wordings.map(({ name }) => name);
|
||||
|
||||
let result = "";
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import type {
|
||||
EndpointLanguage,
|
||||
EndpointWebsiteConfig,
|
||||
EndpointWording,
|
||||
} from "src/shared/payload/endpoint-types";
|
||||
import { SDKEndpointNames, type PayloadSDK } from "src/shared/payload/sdk";
|
||||
import type { EndpointChange } from "src/shared/payload/webhooks";
|
||||
import { getLogger } from "src/utils/logger";
|
||||
|
||||
export class ContextCache {
|
||||
private initialized = false;
|
||||
private logger = getLogger("[ContextCache]");
|
||||
|
||||
constructor(private readonly payload: PayloadSDK) {}
|
||||
|
||||
languages: EndpointLanguage[] = [];
|
||||
locales: string[] = [];
|
||||
currencies: string[] = [];
|
||||
wordings: EndpointWording[] = [];
|
||||
config: EndpointWebsiteConfig = {
|
||||
home: { folders: [] },
|
||||
timeline: { breaks: [], eras: [], eventCount: 0 },
|
||||
};
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
await this.refreshAll();
|
||||
this.initialized = true;
|
||||
this.logger.log("Init complete");
|
||||
}
|
||||
|
||||
private async refreshAll() {
|
||||
await this.refreshCurrencies();
|
||||
await this.refreshLocales();
|
||||
await this.refreshWebsiteConfig();
|
||||
await this.refreshWordings();
|
||||
}
|
||||
|
||||
async invalidate(changes: EndpointChange[]) {
|
||||
for (const change of changes) {
|
||||
switch (change.type) {
|
||||
case SDKEndpointNames.getWordings:
|
||||
return await this.refreshWordings();
|
||||
|
||||
case SDKEndpointNames.getLanguages:
|
||||
return await this.refreshLocales();
|
||||
|
||||
case SDKEndpointNames.getCurrencies:
|
||||
return await this.refreshCurrencies();
|
||||
|
||||
case SDKEndpointNames.getWebsiteConfig:
|
||||
return await this.refreshWebsiteConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshWordings() {
|
||||
this.wordings = (await this.payload.getWordings()).data;
|
||||
this.logger.log("Wordings refreshed");
|
||||
}
|
||||
|
||||
private async refreshCurrencies() {
|
||||
this.currencies = (await this.payload.getCurrencies()).data.map(({ id }) => id);
|
||||
this.logger.log("Currencies refreshed");
|
||||
}
|
||||
|
||||
private async refreshLocales() {
|
||||
this.languages = (await this.payload.getLanguages()).data;
|
||||
this.locales = this.languages.filter(({ selectable }) => selectable).map(({ id }) => id);
|
||||
this.logger.log("Locales refreshed");
|
||||
}
|
||||
|
||||
private async refreshWebsiteConfig() {
|
||||
this.config = (await this.payload.getWebsiteConfig()).data;
|
||||
this.logger.log("WebsiteConfig refreshed");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
import { getLogger } from "src/utils/logger";
|
||||
import { writeFile, mkdir, readFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import type { PayloadSDK } from "src/shared/payload/sdk";
|
||||
import type { EndpointChange } from "src/shared/payload/webhooks";
|
||||
|
||||
const ON_DISK_ROOT = `.cache/dataCache`;
|
||||
const ON_DISK_RESPONSE_CACHE_FILE = `${ON_DISK_ROOT}/responseCache.json`;
|
||||
|
||||
export class DataCache {
|
||||
private readonly logger = getLogger("[DataCache]");
|
||||
private initialized = false;
|
||||
|
||||
private readonly responseCache = new Map<string, any>();
|
||||
|
||||
private scheduleSaveTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly payload: PayloadSDK,
|
||||
private readonly uncachedPayload: PayloadSDK
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (import.meta.env.DATA_PRECACHING === "true") {
|
||||
await this.precache();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private async precache() {
|
||||
// Get all documents from CMS
|
||||
const allDocs = (await this.uncachedPayload.getAll()).data;
|
||||
|
||||
// Load cache from disk if available
|
||||
if (existsSync(ON_DISK_RESPONSE_CACHE_FILE)) {
|
||||
this.logger.log("Loading cache from disk...");
|
||||
const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE);
|
||||
const data = JSON.parse(buffer.toString()) as [string, any][];
|
||||
for (const [key, value] of data) {
|
||||
// Do not include cache where the key is no longer in the CMS
|
||||
if (!allDocs.find(({ url }) => url === key)) continue;
|
||||
this.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const cacheSizeBeforePrecaching = this.responseCache.size;
|
||||
|
||||
for (const doc of allDocs) {
|
||||
// Do not precache response if already included in the loaded cache from disk
|
||||
if (this.responseCache.has(doc.url)) continue;
|
||||
try {
|
||||
await this.payload.request(doc.url);
|
||||
} catch {
|
||||
this.logger.warn("Precaching failed for url", doc.url);
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheSizeBeforePrecaching !== this.responseCache.size) {
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
this.logger.log("Precaching completed!", this.responseCache.size, "responses cached");
|
||||
}
|
||||
|
||||
get(url: string) {
|
||||
if (import.meta.env.DATA_CACHING !== "true") return;
|
||||
const cachedResponse = this.responseCache.get(url);
|
||||
if (cachedResponse) {
|
||||
this.logger.log("Retrieved cached response for", url);
|
||||
return structuredClone(cachedResponse);
|
||||
}
|
||||
}
|
||||
|
||||
set(url: string, response: any) {
|
||||
if (import.meta.env.DATA_CACHING !== "true") return;
|
||||
this.responseCache.set(url, response);
|
||||
this.logger.log("Cached response for", url);
|
||||
if (this.initialized) {
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
async invalidate(changes: EndpointChange[]) {
|
||||
if (import.meta.env.DATA_CACHING !== "true") return;
|
||||
|
||||
const urls = changes.map(({ url }) => url);
|
||||
for (const url of urls) {
|
||||
this.responseCache.delete(url);
|
||||
this.logger.log("Invalidated cache for", url);
|
||||
try {
|
||||
await this.payload.request(url);
|
||||
} catch (e) {
|
||||
this.logger.log("Revalidation fails for", url);
|
||||
}
|
||||
}
|
||||
this.logger.log("There are currently", this.responseCache.size, "responses in cache.");
|
||||
if (this.initialized) {
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSave() {
|
||||
if (this.scheduleSaveTimeout) {
|
||||
clearTimeout(this.scheduleSaveTimeout);
|
||||
}
|
||||
this.scheduleSaveTimeout = setTimeout(() => {
|
||||
this.save();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
private async save() {
|
||||
if (!existsSync(ON_DISK_ROOT)) {
|
||||
await mkdir(ON_DISK_ROOT, { recursive: true });
|
||||
}
|
||||
|
||||
const serializedResponseCache = JSON.stringify([...this.responseCache]);
|
||||
await writeFile(ON_DISK_RESPONSE_CACHE_FILE, serializedResponseCache, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
this.logger.log("Saved", ON_DISK_RESPONSE_CACHE_FILE);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,235 @@
|
|||
import { getLogger } from "src/utils/logger";
|
||||
import { writeFile, mkdir, readFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import {
|
||||
deserializeResponse,
|
||||
serializeResponse,
|
||||
type SerializableResponse,
|
||||
} from "src/utils/responses";
|
||||
import { SDKEndpointNames, type PayloadSDK } from "src/shared/payload/sdk";
|
||||
import type { EndpointChange } from "src/shared/payload/webhooks";
|
||||
import type { ContextCache } from "src/cache/contextCache";
|
||||
|
||||
const ON_DISK_ROOT = `.cache/pageCache`;
|
||||
const ON_DISK_RESPONSE_CACHE_FILE = `${ON_DISK_ROOT}/responseCache.json`;
|
||||
|
||||
export class PageCache {
|
||||
private readonly logger = getLogger("[PageCache]");
|
||||
private initialized = false;
|
||||
|
||||
private responseCache = new Map<string, Response>();
|
||||
|
||||
private scheduleSaveTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly uncachedPayload: PayloadSDK,
|
||||
private readonly contextCache: ContextCache
|
||||
) {}
|
||||
|
||||
get(url: string): Response | undefined {
|
||||
if (import.meta.env.PAGE_CACHING !== "true") return;
|
||||
const cachedPage = this.responseCache.get(url);
|
||||
if (cachedPage) {
|
||||
this.logger.log("Retrieved cached page for", url);
|
||||
return cachedPage?.clone();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
set(url: string, response: Response) {
|
||||
if (import.meta.env.PAGE_CACHING !== "true") return;
|
||||
this.responseCache.set(url, response.clone());
|
||||
this.logger.log("Cached response for", url);
|
||||
if (this.initialized) {
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (import.meta.env.PAGE_PRECACHING === "true") {
|
||||
await this.precache();
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private async precache() {
|
||||
if (import.meta.env.DATA_CACHING !== "true") return;
|
||||
|
||||
const allPageUrls = await this.getAllUrls();
|
||||
|
||||
// Load cache from disk if available
|
||||
if (existsSync(ON_DISK_RESPONSE_CACHE_FILE)) {
|
||||
this.logger.log("Loading cache from disk...");
|
||||
|
||||
const buffer = await readFile(ON_DISK_RESPONSE_CACHE_FILE);
|
||||
const data = JSON.parse(buffer.toString()) as [string, SerializableResponse][];
|
||||
let deserializedData = data.map<[string, Response]>(([key, value]) => [
|
||||
key,
|
||||
deserializeResponse(value),
|
||||
]);
|
||||
|
||||
// Do not include cache where the key is no longer in the CMS
|
||||
deserializedData = deserializedData.filter(([key]) => allPageUrls.has(key));
|
||||
|
||||
this.responseCache = new Map(deserializedData);
|
||||
}
|
||||
|
||||
const cacheSizeBeforePrecaching = this.responseCache.size;
|
||||
|
||||
for (const url of allPageUrls) {
|
||||
// Do not precache response if already included in the loaded cache from disk
|
||||
if (this.responseCache.has(url)) continue;
|
||||
try {
|
||||
await fetch(`http://${import.meta.env.ASTRO_HOST}:${import.meta.env.ASTRO_PORT}${url}`);
|
||||
} catch (e) {
|
||||
this.logger.warn("Precaching failed for page", url);
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheSizeBeforePrecaching !== this.responseCache.size) {
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
this.logger.log("Precaching completed!", this.responseCache.size, "responses cached");
|
||||
}
|
||||
|
||||
async invalidate(changes: EndpointChange[]) {
|
||||
if (import.meta.env.PAGE_CACHING !== "true") return;
|
||||
|
||||
if (
|
||||
changes.some(({ type }) =>
|
||||
[
|
||||
SDKEndpointNames.getWebsiteConfig,
|
||||
SDKEndpointNames.getLanguages,
|
||||
SDKEndpointNames.getCurrencies,
|
||||
SDKEndpointNames.getWordings,
|
||||
].includes(type)
|
||||
)
|
||||
) {
|
||||
await this.resetAllUrls();
|
||||
return;
|
||||
}
|
||||
|
||||
const pagesToInvalidate = new Set<string>(
|
||||
changes.flatMap((change) => this.getUrlFromEndpointChange(change))
|
||||
);
|
||||
|
||||
for (const url of pagesToInvalidate) {
|
||||
this.responseCache.delete(url);
|
||||
this.logger.log("Invalidated cache for", url);
|
||||
try {
|
||||
await fetch(`http://${import.meta.env.ASTRO_HOST}:${import.meta.env.ASTRO_PORT}${url}`);
|
||||
} catch (e) {
|
||||
this.logger.log("Revalidation fails for", url);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log("There are currently", this.responseCache.size, "responses in cache.");
|
||||
if (this.initialized) {
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
private async resetAllUrls() {
|
||||
const allUrls = await this.getAllUrls();
|
||||
this.responseCache.clear();
|
||||
|
||||
for (const url of allUrls) {
|
||||
try {
|
||||
await fetch(`http://${import.meta.env.ASTRO_HOST}:${import.meta.env.ASTRO_PORT}${url}`);
|
||||
} catch (e) {
|
||||
this.logger.warn("Reset failed for page", url);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.initialized) {
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSave() {
|
||||
if (this.scheduleSaveTimeout) {
|
||||
clearTimeout(this.scheduleSaveTimeout);
|
||||
}
|
||||
this.scheduleSaveTimeout = setTimeout(() => {
|
||||
this.save();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
private async save() {
|
||||
if (!existsSync(ON_DISK_ROOT)) {
|
||||
await mkdir(ON_DISK_ROOT, { recursive: true });
|
||||
}
|
||||
|
||||
const serializedResponses = await Promise.all(
|
||||
[...this.responseCache].map(async ([key, value]) => [key, await serializeResponse(value)])
|
||||
);
|
||||
const serializedResponseCache = JSON.stringify(serializedResponses);
|
||||
await writeFile(ON_DISK_RESPONSE_CACHE_FILE, serializedResponseCache, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
this.logger.log("Saved", ON_DISK_RESPONSE_CACHE_FILE);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------------------------- */
|
||||
|
||||
private getLocalizedUrlsFromUrl = (urls: string[]) => {
|
||||
return urls.flatMap((url) => this.contextCache.locales.map((id) => `/${id}${url}`));
|
||||
};
|
||||
|
||||
private async getAllUrls() {
|
||||
const allChanges = (await this.uncachedPayload.getAll()).data;
|
||||
return new Set<string>([
|
||||
...this.getLocalizedUrlsFromUrl(["", "/settings", "/search"]),
|
||||
...allChanges.flatMap((change) => this.getUrlFromEndpointChange(change)),
|
||||
]);
|
||||
}
|
||||
|
||||
private getUrlFromEndpointChange(change: EndpointChange): string[] {
|
||||
const getUnlocalizedUrl = (): string[] => {
|
||||
switch (change.type) {
|
||||
case SDKEndpointNames.getFolder:
|
||||
return [`/folders/${change.slug}`];
|
||||
|
||||
case SDKEndpointNames.getCollectible:
|
||||
return [`/collectibles/${change.slug}`, `/collectibles/${change.slug}/relations`];
|
||||
|
||||
case SDKEndpointNames.getCollectibleGallery:
|
||||
return [`/collectibles/${change.slug}/gallery`];
|
||||
|
||||
case SDKEndpointNames.getCollectibleScans:
|
||||
return [`/collectibles/${change.slug}/scans`];
|
||||
|
||||
case SDKEndpointNames.getPage:
|
||||
return [`/pages/${change.slug}`, `/pages/${change.slug}/relations`];
|
||||
|
||||
case SDKEndpointNames.getAudioByID:
|
||||
return [`/audios/${change.id}`];
|
||||
|
||||
case SDKEndpointNames.getImageByID:
|
||||
return [`/images/${change.id}`];
|
||||
|
||||
case SDKEndpointNames.getVideoByID:
|
||||
return [`/videos/${change.id}`];
|
||||
|
||||
case SDKEndpointNames.getFileByID:
|
||||
return [`/files/${change.id}`];
|
||||
|
||||
case SDKEndpointNames.getRecorderByID:
|
||||
return [`/recorders/${change.id}`];
|
||||
|
||||
case SDKEndpointNames.getChronologyEvents:
|
||||
case SDKEndpointNames.getChronologyEventByID:
|
||||
return [`/timeline`];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return this.getLocalizedUrlsFromUrl(getUnlocalizedUrl());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
export class TokenCache {
|
||||
private token: string | undefined;
|
||||
private expiration: number | undefined;
|
||||
|
||||
get() {
|
||||
if (!this.token) return undefined;
|
||||
if (!this.expiration || this.expiration < Date.now()) {
|
||||
console.log("[PayloadSDK] No token to be retrieved or the token expired");
|
||||
return undefined;
|
||||
}
|
||||
return this.token;
|
||||
}
|
||||
|
||||
set(newToken: string, newExpiration: number) {
|
||||
this.token = newToken;
|
||||
this.expiration = newExpiration * 1000;
|
||||
const diffInMinutes = Math.floor((this.expiration - Date.now()) / 1000 / 60);
|
||||
console.log("[PayloadSDK] New token set. TTL is", diffInMinutes, "minutes.");
|
||||
}
|
||||
}
|
|
@ -2,25 +2,22 @@
|
|||
import Html from "./components/Html.astro";
|
||||
import Topbar from "./components/Topbar/Topbar.astro";
|
||||
import Footer from "./components/Footer.astro";
|
||||
import type { EndpointSource } from "src/shared/payload/payload-sdk";
|
||||
import AppLayoutBackgroundImg from "./components/AppLayoutBackgroundImg.astro";
|
||||
import type { ComponentProps } from "astro/types";
|
||||
|
||||
interface Props {
|
||||
openGraph?: ComponentProps<typeof Html>["openGraph"];
|
||||
parentPages?: EndpointSource[];
|
||||
backgroundImage?: ComponentProps<typeof AppLayoutBackgroundImg>["img"] | undefined;
|
||||
hideFooterLinks?: boolean;
|
||||
hideHomeButton?: boolean;
|
||||
class?: string | undefined;
|
||||
topBar?: ComponentProps<typeof Topbar>;
|
||||
}
|
||||
|
||||
const {
|
||||
openGraph,
|
||||
parentPages,
|
||||
backgroundImage,
|
||||
hideFooterLinks = false,
|
||||
hideHomeButton = false,
|
||||
topBar = {},
|
||||
...otherProps
|
||||
} = Astro.props;
|
||||
---
|
||||
|
@ -30,7 +27,7 @@ const {
|
|||
<Html openGraph={openGraph}>
|
||||
{backgroundImage && <AppLayoutBackgroundImg img={backgroundImage} />}
|
||||
<header>
|
||||
<Topbar parentPages={parentPages} hideHomeButton={hideHomeButton} />
|
||||
<Topbar {...topBar} />
|
||||
</header>
|
||||
<main {...otherProps.class ? otherProps : {}}><slot /></main>
|
||||
<Footer withLinks={!hideFooterLinks} />
|
||||
|
|
|
@ -30,20 +30,32 @@ const { reducedAsideWidth = false } = Astro.props;
|
|||
<div id="layout" class:list={{ "reduced-width": reducedAsideWidth }}>
|
||||
<div id="left">
|
||||
<slot name="header" />
|
||||
<div class="when-not-large">
|
||||
<slot name="header-aside" />
|
||||
<slot name="meta" />
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
<div class="when-large">
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
{
|
||||
(Astro.slots.has("header-aside") || Astro.slots.has("meta") || Astro.slots.has("aside")) && (
|
||||
<div class="when-not-large">
|
||||
<slot name="header-aside" />
|
||||
<slot name="meta" />
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
Astro.slots.has("meta") && (
|
||||
<div class="when-large">
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
<Card class="when-large right">
|
||||
<slot name="header-aside" />
|
||||
<slot name="aside" />
|
||||
</Card>
|
||||
{
|
||||
(Astro.slots.has("header-aside") || Astro.slots.has("aside")) && (
|
||||
<Card class="when-large right">
|
||||
<slot name="header-aside" />
|
||||
<slot name="aside" />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
---
|
||||
import type {
|
||||
EndpointImage,
|
||||
EndpointMediaThumbnail,
|
||||
EndpointScanImage,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import { getRandomId } from "src/utils/random";
|
||||
import { sizesToSrcset } from "src/utils/img";
|
||||
import type {
|
||||
EndpointImage,
|
||||
EndpointPayloadImage,
|
||||
EndpointScanImage,
|
||||
} from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
img: EndpointImage | EndpointMediaThumbnail | EndpointScanImage;
|
||||
img: EndpointImage | EndpointPayloadImage | EndpointScanImage;
|
||||
}
|
||||
|
||||
const {
|
||||
|
@ -100,6 +100,7 @@ const uniqueId = getRandomId();
|
|||
const img = document.getElementById(uniqueId);
|
||||
const parent = img.parentElement;
|
||||
if (!parent || !img) return;
|
||||
if (img.complete) return;
|
||||
|
||||
parent.style.animationPlayState = "paused";
|
||||
img.addEventListener(
|
||||
|
|
|
@ -7,7 +7,7 @@ interface Props {
|
|||
}
|
||||
|
||||
const { withLinks } = Astro.props;
|
||||
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||
const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const discordLabel = `${t("footer.socials.discord.title")} - ${t(
|
||||
"footer.socials.discord.subtitle"
|
||||
|
@ -30,7 +30,11 @@ const contactLabel = `${t("footer.socials.contact.title")} - ${t(
|
|||
{
|
||||
withLinks && (
|
||||
<div id="socials" class="when-no-print">
|
||||
<a href="/discord" class="pressable-label" aria-label={discordLabel} title={discordLabel}>
|
||||
<a
|
||||
href={getLocalizedUrl("/discord")}
|
||||
class="pressable-label"
|
||||
aria-label={discordLabel}
|
||||
title={discordLabel}>
|
||||
<Icon name="discord-brands" />
|
||||
<p class="font-s">{t("footer.socials.discord.title")}</p>
|
||||
</a>
|
||||
|
@ -101,6 +105,7 @@ const contactLabel = `${t("footer.socials.contact.title")} - ${t(
|
|||
grid-template-columns: auto auto;
|
||||
gap: 0.2em 1em;
|
||||
place-content: start;
|
||||
place-items: start;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (min-width: 720.5px) {
|
||||
|
|
|
@ -1,20 +1,21 @@
|
|||
---
|
||||
import UAParser from "ua-parser-js";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import { contextCache } from "src/services";
|
||||
import { PostProcessingTags } from "src/middleware/postProcessing";
|
||||
import type {
|
||||
EndpointAudio,
|
||||
EndpointImage,
|
||||
EndpointMediaThumbnail,
|
||||
EndpointPayloadImage,
|
||||
EndpointVideo,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import { cache } from "src/utils/payload";
|
||||
} from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
openGraph?:
|
||||
| {
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
thumbnail?: EndpointImage | EndpointMediaThumbnail | undefined;
|
||||
thumbnail?: EndpointImage | EndpointPayloadImage | undefined;
|
||||
audio?: EndpointAudio | undefined;
|
||||
video?: EndpointVideo | undefined;
|
||||
}
|
||||
|
@ -28,7 +29,9 @@ const { openGraph = {} } = Astro.props;
|
|||
const { description = t("global.meta.description"), audio, video } = openGraph;
|
||||
|
||||
const thumbnail =
|
||||
openGraph.thumbnail?.openGraph ?? openGraph.thumbnail ?? cache.config.defaultOpenGraphImage;
|
||||
openGraph.thumbnail?.openGraph ??
|
||||
openGraph.thumbnail ??
|
||||
contextCache.config.defaultOpenGraphImage;
|
||||
|
||||
const title = openGraph.title
|
||||
? `${openGraph.title} – ${t("global.siteName")}`
|
||||
|
@ -38,8 +41,6 @@ const userAgent = Astro.request.headers.get("user-agent") ?? "";
|
|||
const parser = new UAParser(userAgent);
|
||||
const isIOS = parser.getOS().name === "iOS";
|
||||
|
||||
const { currentTheme } = Astro.locals;
|
||||
|
||||
/* Keep that separator here or else it breaks the HTML
|
||||
----------------------------------------------- HTML -------------------------------------------- */
|
||||
---
|
||||
|
@ -47,9 +48,7 @@ const { currentTheme } = Astro.locals;
|
|||
<html
|
||||
lang={currentLocale}
|
||||
class:list={{
|
||||
"manual-theme": currentTheme !== "auto",
|
||||
"light-theme": currentTheme === "light",
|
||||
"dark-theme": currentTheme === "dark",
|
||||
[PostProcessingTags.HTML_CLASS]: true,
|
||||
"texture-dots": !isIOS,
|
||||
"font-m": true,
|
||||
"debug-lang": false,
|
||||
|
@ -59,8 +58,6 @@ const { currentTheme } = Astro.locals;
|
|||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
|
||||
<link rel="stylesheet" href="/css/tippy.css" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
@ -75,6 +72,8 @@ const { currentTheme } = Astro.locals;
|
|||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" href="/css/tippy.css" />
|
||||
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fdebd4" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#27231e" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
@ -131,16 +130,6 @@ const { currentTheme } = Astro.locals;
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<noscript>
|
||||
<style is:global>
|
||||
.when-js {
|
||||
display: none !important;
|
||||
visibility: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@ -310,7 +299,7 @@ const { currentTheme } = Astro.locals;
|
|||
color: var(--color-base-1000);
|
||||
}
|
||||
|
||||
html:not(.manual-theme) {
|
||||
html:not(.light-theme, .dark-theme) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
& .when-dark-theme {
|
||||
display: none !important;
|
||||
|
@ -685,10 +674,14 @@ const { currentTheme } = Astro.locals;
|
|||
display: flex;
|
||||
place-items: center;
|
||||
gap: 0.4em;
|
||||
padding: 0.7em 0.8em;
|
||||
padding: 0.7em 1.1em;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
|
||||
&:has(svg) {
|
||||
padding-left: 0.8em;
|
||||
}
|
||||
|
||||
transition: 150ms background-color;
|
||||
|
||||
&:hover,
|
||||
|
@ -816,14 +809,26 @@ const { currentTheme } = Astro.locals;
|
|||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (scripting: none) {
|
||||
.when-js {
|
||||
display: none !important;
|
||||
visibility: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (scripting: enabled) {
|
||||
.when-no-js {
|
||||
display: none !important;
|
||||
visibility: none !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{/* ------------------------------------------- JS --------------------------------------------- */}
|
||||
|
||||
<script is:inline>
|
||||
Array.from(document.querySelectorAll(".when-no-js")).forEach((node) => node.remove());
|
||||
</script>
|
||||
|
||||
<script is:inline>
|
||||
document.querySelectorAll("a").forEach((element) => {
|
||||
const href = element.getAttribute("href");
|
||||
|
|
|
@ -4,16 +4,23 @@ import Button from "components/Button.astro";
|
|||
import ThemeSelector from "./components/ThemeSelector.astro";
|
||||
import LanguageSelector from "./components/LanguageSelector.astro";
|
||||
import CurrencySelector from "./components/CurrencySelector.astro";
|
||||
import ParentPagesButton from "./components/ParentPagesButton.astro";
|
||||
import RelationsButton from "./components/RelationsButton.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointSource } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointRelation } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
parentPages?: EndpointSource[] | undefined;
|
||||
hideHomeButton?: boolean;
|
||||
relations?: EndpointRelation[] | undefined;
|
||||
relationPageUrl?: string | undefined;
|
||||
hideHomeButton?: boolean | undefined;
|
||||
hideSearchButton?: boolean | undefined;
|
||||
}
|
||||
|
||||
const { parentPages = [], hideHomeButton = false } = Astro.props;
|
||||
const {
|
||||
relations = [],
|
||||
relationPageUrl,
|
||||
hideHomeButton = false,
|
||||
hideSearchButton = false,
|
||||
} = Astro.props;
|
||||
|
||||
const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
|
||||
---
|
||||
|
@ -22,23 +29,31 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
|
|||
|
||||
<nav id="topbar" class="when-no-print">
|
||||
{
|
||||
(!hideHomeButton || parentPages.length > 0) && (
|
||||
(!hideHomeButton || relations.length > 0) && (
|
||||
<div id="left" class="hide-scrollbar">
|
||||
<a href={getLocalizedUrl("/")} class="pressable-label">
|
||||
<a href={getLocalizedUrl("")} class="pressable-label">
|
||||
<Icon name="material-symbols:home" width={16} height={16} />
|
||||
<p>{t("home.title")}</p>
|
||||
</a>
|
||||
|
||||
{parentPages.length > 0 && <ParentPagesButton parentPages={parentPages} />}
|
||||
{relations.length > 0 && (
|
||||
<RelationsButton relations={relations} relationPageUrl={relationPageUrl} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div id="toolbar" class="hide-scrollbar">
|
||||
<a href={getLocalizedUrl("/search")} aria-label={t("header.topbar.search.tooltip")} hidden>
|
||||
<Button icon="material-symbols:search" />
|
||||
</a>
|
||||
<div class="separator" hidden></div>
|
||||
{
|
||||
!hideSearchButton && (
|
||||
<>
|
||||
<a href={getLocalizedUrl("/search")} title={t("header.topbar.search.tooltip")}>
|
||||
<Button icon="material-symbols:search" />
|
||||
</a>
|
||||
<div class="separator" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="when-no-js">
|
||||
<a href="/settings">
|
||||
|
|
|
@ -2,8 +2,12 @@
|
|||
import Button from "components/Button.astro";
|
||||
import Tooltip from "components/Tooltip.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import { cache } from "src/utils/payload";
|
||||
import { contextCache } from "src/services";
|
||||
import { formatCurrency } from "src/utils/currencies";
|
||||
import {
|
||||
PostProcessingTags,
|
||||
prepareClassForSelectedCurrencyPostProcessing,
|
||||
} from "src/middleware/postProcessing";
|
||||
|
||||
interface Props {
|
||||
withTitle?: boolean | undefined;
|
||||
|
@ -13,7 +17,12 @@ interface Props {
|
|||
const { withTitle, ...otherProps } = Astro.props;
|
||||
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const { currentCurrency } = Astro.locals;
|
||||
const getActionCurrency = (value: string) => {
|
||||
const requestSearchParams = new URL(Astro.request.url).searchParams;
|
||||
const newSearchParams = new URLSearchParams(requestSearchParams);
|
||||
newSearchParams.set("action-currency", value);
|
||||
return `?${newSearchParams}`;
|
||||
};
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
@ -21,14 +30,13 @@ const { currentCurrency } = Astro.locals;
|
|||
<Tooltip trigger="click" {...otherProps.class ? otherProps : {}}>
|
||||
<div id="content" slot="tooltip-content">
|
||||
{
|
||||
cache.currencies.map((id) => (
|
||||
contextCache.currencies.map((id) => (
|
||||
<a
|
||||
class:list={{
|
||||
current: currentCurrency === id,
|
||||
"pressable-link": true,
|
||||
}}
|
||||
href={`?action-currency=${id}`}
|
||||
data-astro-prefetch="tap">
|
||||
class:list={[
|
||||
"pressable-link",
|
||||
prepareClassForSelectedCurrencyPostProcessing({ currency: id }),
|
||||
]}
|
||||
href={getActionCurrency(id)}>
|
||||
{`${id} (${formatCurrency(id)})`}
|
||||
</a>
|
||||
))
|
||||
|
@ -36,7 +44,7 @@ const { currentCurrency } = Astro.locals;
|
|||
</div>
|
||||
<Button
|
||||
icon="material-symbols:currency-exchange"
|
||||
title={withTitle ? currentCurrency.toUpperCase() : undefined}
|
||||
title={withTitle ? PostProcessingTags.PREFERRED_CURRENCY : undefined}
|
||||
ariaLabel={t("header.topbar.currency.tooltip")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import Button from "components/Button.astro";
|
||||
import Tooltip from "components/Tooltip.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import { cache } from "src/utils/payload";
|
||||
import { contextCache } from "src/services";
|
||||
import { formatLocale } from "src/utils/format";
|
||||
|
||||
interface Props {
|
||||
|
@ -14,6 +14,13 @@ const { withTitle, ...otherProps } = Astro.props;
|
|||
|
||||
const { currentLocale } = Astro.locals;
|
||||
const { t } = await getI18n(currentLocale);
|
||||
|
||||
const getActionLanguage = (value: string) => {
|
||||
const requestSearchParams = new URL(Astro.request.url).searchParams;
|
||||
const newSearchParams = new URLSearchParams(requestSearchParams);
|
||||
newSearchParams.set("action-lang", value);
|
||||
return `?${newSearchParams}`;
|
||||
};
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
@ -21,12 +28,11 @@ const { t } = await getI18n(currentLocale);
|
|||
<Tooltip trigger="click" {...otherProps.class ? otherProps : {}}>
|
||||
<div id="content" slot="tooltip-content">
|
||||
{
|
||||
cache.locales.map(({ id }) => (
|
||||
contextCache.locales.map((locale) => (
|
||||
<a
|
||||
class:list={{ current: currentLocale === id, "pressable-link": true }}
|
||||
href={`?action-lang=${id}`}
|
||||
data-astro-prefetch="tap">
|
||||
{formatLocale(id)}
|
||||
class:list={{ current: currentLocale === locale, "pressable-link": true }}
|
||||
href={getActionLanguage(locale)}>
|
||||
{formatLocale(locale)}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
---
|
||||
import Tooltip from "components/Tooltip.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import ReturnToButton from "./ReturnToButton.astro";
|
||||
import type { EndpointSource } from "src/shared/payload/payload-sdk";
|
||||
import SourceRow from "components/SourceRow.astro";
|
||||
|
||||
interface Props {
|
||||
parentPages: EndpointSource[];
|
||||
}
|
||||
|
||||
const { parentPages } = Astro.props;
|
||||
|
||||
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
{
|
||||
parentPages.length === 1 && parentPages[0] ? (
|
||||
<ReturnToButton parentPage={parentPages[0]} />
|
||||
) : (
|
||||
<Tooltip trigger="click" class="when-js">
|
||||
<div id="tooltip-content" slot="tooltip-content">
|
||||
<p>{t("header.nav.parentPages.tooltip")}</p>
|
||||
<div>
|
||||
{parentPages.map((parentPage) => (
|
||||
<SourceRow source={parentPage} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button class="pressable-label">
|
||||
<Icon name="material-symbols:keyboard-return" />
|
||||
<p>
|
||||
{t("header.nav.parentPages.label", {
|
||||
count: parentPages.length,
|
||||
})}
|
||||
</p>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
#tooltip-content {
|
||||
> p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
import Tooltip from "components/Tooltip.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import ReturnToButton from "./ReturnToButton.astro";
|
||||
import RelationRow from "components/RelationRow.astro";
|
||||
import type { EndpointRelation } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
relations: EndpointRelation[];
|
||||
relationPageUrl?: string | undefined;
|
||||
}
|
||||
|
||||
const { relations, relationPageUrl } = Astro.props;
|
||||
|
||||
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const buttonLabel = t("header.nav.parentPages.label", {
|
||||
count: relations.length,
|
||||
});
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
{
|
||||
relations.length === 1 && relations[0] ? (
|
||||
<ReturnToButton relation={relations[0]} />
|
||||
) : (
|
||||
<>
|
||||
<Tooltip trigger="click" class="when-js">
|
||||
<div id="tooltip-content" slot="tooltip-content">
|
||||
<p>{t("header.nav.parentPages.tooltip")}</p>
|
||||
<div>
|
||||
{relations.slice(0, 5).map((relation) => (
|
||||
<RelationRow relation={relation} />
|
||||
))}
|
||||
{relationPageUrl && (
|
||||
<>
|
||||
<hr />
|
||||
<a href={relationPageUrl} class="pressable-link">
|
||||
{t("header.nav.parentPages.tooltip.viewAll")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pressable-label">
|
||||
<Icon name="material-symbols:keyboard-return" />
|
||||
<p>{buttonLabel}</p>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{relationPageUrl && (
|
||||
<a class="pressable-label when-no-js" href={relationPageUrl}>
|
||||
<Icon name="material-symbols:keyboard-return" />
|
||||
<div>{buttonLabel}</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
#tooltip-content {
|
||||
> p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8em;
|
||||
|
||||
& > hr {
|
||||
border: none;
|
||||
border-top: 2px dotted var(--color-base-500);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
& > .pressable-link {
|
||||
padding: 4px 10px;
|
||||
margin: -4px -10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,14 +1,14 @@
|
|||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointSource } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointRelation } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
parentPage: EndpointSource;
|
||||
relation: EndpointRelation;
|
||||
}
|
||||
|
||||
const { parentPage } = Astro.props;
|
||||
const { formatEndpointSource } = await getI18n(Astro.locals.currentLocale);
|
||||
const { relation } = Astro.props;
|
||||
const { formatEndpointRelation } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const {
|
||||
href,
|
||||
|
@ -17,7 +17,7 @@ const {
|
|||
target = undefined,
|
||||
rel = undefined,
|
||||
lang,
|
||||
} = formatEndpointSource(parentPage);
|
||||
} = formatEndpointRelation(relation);
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
import Button from "components/Button.astro";
|
||||
import Card from "components/Card.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointRelation } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
parent: EndpointRelation;
|
||||
}
|
||||
|
||||
const { parent } = Astro.props;
|
||||
|
||||
const { formatEndpointRelation, t } = await getI18n(Astro.locals.currentLocale);
|
||||
const { href, target, label } = formatEndpointRelation(parent);
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<Card class="card_subpage">
|
||||
<p class="font-l">{t("global.subpageCard.message", { title: label })}</p>
|
||||
<a href={href} target={target}>
|
||||
<Button title={t("global.subpageCard.returnButton")} icon="material-symbols:keyboard-return" />
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
.card_subpage {
|
||||
padding: 2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
width: fit-content;
|
||||
place-items: start;
|
||||
}
|
||||
</style>
|
|
@ -3,27 +3,28 @@ import Button from "components/Button.astro";
|
|||
import Tooltip from "components/Tooltip.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
|
||||
const { currentLocale, currentTheme } = Astro.locals;
|
||||
const { currentLocale } = Astro.locals;
|
||||
const { t } = await getI18n(currentLocale);
|
||||
|
||||
const getActionThemeUrl = (value: "dark" | "light" | "auto") => {
|
||||
const requestSearchParams = new URL(Astro.request.url).searchParams;
|
||||
const newSearchParams = new URLSearchParams(requestSearchParams);
|
||||
newSearchParams.set("action-theme", value);
|
||||
return `?${newSearchParams}`;
|
||||
};
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<Tooltip trigger="click">
|
||||
<div id="content" slot="tooltip-content">
|
||||
<a
|
||||
class:list={{ current: currentTheme === "dark", "pressable-link": true }}
|
||||
href="?action-theme=dark">
|
||||
<a class="pressable-link underline-when-dark" href={getActionThemeUrl("dark")}>
|
||||
{t("global.theme.dark")}
|
||||
</a>
|
||||
<a
|
||||
class:list={{ current: currentTheme === "auto", "pressable-link": true }}
|
||||
href="?action-theme=auto">
|
||||
<a class="pressable-link underline-when-auto" href={getActionThemeUrl("auto")}>
|
||||
{t("global.theme.auto")}
|
||||
</a>
|
||||
<a
|
||||
class:list={{ current: currentTheme === "light", "pressable-link": true }}
|
||||
href="?action-theme=light">
|
||||
<a class="pressable-link underline-when-light" href={getActionThemeUrl("light")}>
|
||||
{t("global.theme.light")}
|
||||
</a>
|
||||
</div>
|
||||
|
@ -45,10 +46,12 @@ const { t } = await getI18n(currentLocale);
|
|||
#content {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
& > .current {
|
||||
color: var(--color-base-750);
|
||||
text-decoration: underline 0.08em var(--color-base-650);
|
||||
}
|
||||
:global(html.light-theme) a.underline-when-light,
|
||||
:global(html.dark-theme) a.underline-when-dark,
|
||||
:global(html:not(.light-theme, .dark-theme)) a.underline-when-auto {
|
||||
color: var(--color-base-750);
|
||||
text-decoration: underline 0.08em var(--color-base-650);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
---
|
||||
import { AttributeTypes, type EndpointAttribute } from "src/shared/payload/payload-sdk";
|
||||
import Metadata from "./Metadata.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import ErrorMessage from "./ErrorMessage.astro";
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
import type { EndpointAttribute } from "src/shared/payload/endpoint-types";
|
||||
import { AttributeTypes } from "src/shared/payload/constants";
|
||||
|
||||
interface Props {
|
||||
attributes: (EndpointAttribute | Attribute)[];
|
||||
|
@ -67,12 +68,7 @@ const { getLocalizedMatch, getLocalizedUrl, formatNumber } = await getI18n(
|
|||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<ErrorMessage
|
||||
title={`Unknown attribute type: ${type}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
);
|
||||
return <ErrorMessage title={`Unknown attribute type: ${type}`} />;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
import type { EndpointAudio } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointAudioPreview } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
audio: EndpointAudio;
|
||||
audio: EndpointAudioPreview;
|
||||
class?: string | undefined;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
---
|
||||
import {
|
||||
isBlockLineBlock,
|
||||
type GenericBlock,
|
||||
isBlockCueBlock,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
|
||||
import LineBlock from "./components/LineBlock.astro";
|
||||
import CueBlock from "./components/CueBlock.astro";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import { type GenericBlock, isBlockLineBlock, isBlockCueBlock } from "src/shared/payload/blocks";
|
||||
|
||||
interface Props {
|
||||
block: GenericBlock;
|
||||
|
@ -25,9 +20,6 @@ const { block, lang } = Astro.props;
|
|||
) : isBlockCueBlock(block) ? (
|
||||
<CueBlock block={block} lang={lang} />
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown block type: ${block.blockType}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown block type: ${block.blockType}`} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import RichText from "components/RichText/RichText.astro";
|
||||
import type { CueBlock } from "src/shared/payload/payload-sdk";
|
||||
import type { CueBlock } from "src/shared/payload/blocks";
|
||||
|
||||
interface Props {
|
||||
block: CueBlock;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import RichText from "components/RichText/RichText.astro";
|
||||
import type { LineBlock } from "src/shared/payload/payload-sdk";
|
||||
import type { LineBlock } from "src/shared/payload/blocks";
|
||||
|
||||
interface Props {
|
||||
block: LineBlock;
|
||||
|
|
|
@ -5,20 +5,21 @@ interface Props {
|
|||
id?: string;
|
||||
title?: string | undefined;
|
||||
icon?: string;
|
||||
class?: string;
|
||||
ariaLabel?: string;
|
||||
class?: string | undefined;
|
||||
}
|
||||
|
||||
const { title, icon, class: className, ariaLabel, id } = Astro.props;
|
||||
const { title, icon, ariaLabel, id, ...otherProps } = Astro.props;
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<button
|
||||
id={id}
|
||||
class:list={["pressable", { "with-title": !!title }, className]}
|
||||
class:list={["pressable", { "with-title": !!title }, otherProps.class]}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}>
|
||||
title={ariaLabel}
|
||||
{...otherProps.class ? otherProps : {}}>
|
||||
{icon && <Icon name={icon} />}
|
||||
{title}
|
||||
</button>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import type { EndpointCredit } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointCredit } from "src/shared/payload/endpoint-types";
|
||||
import Metadata from "./Metadata.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
|
||||
|
@ -30,6 +30,7 @@ const { getLocalizedMatch, getLocalizedUrl } = await getI18n(Astro.locals.curren
|
|||
);
|
||||
})
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
@ -37,6 +38,6 @@ const { getLocalizedMatch, getLocalizedUrl } = await getI18n(Astro.locals.curren
|
|||
<style>
|
||||
div {
|
||||
display: grid;
|
||||
gap: 2em;
|
||||
gap: 1.5em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,18 +5,27 @@ import Button from "./Button.astro";
|
|||
interface Props {
|
||||
href: string;
|
||||
filename: string;
|
||||
useBlob?: boolean;
|
||||
}
|
||||
|
||||
const { href, filename } = Astro.props;
|
||||
const { href, filename, useBlob = false } = Astro.props;
|
||||
|
||||
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<download-button href={href} filename={filename} class="when-js when-no-print">
|
||||
<Button title={t("global.downloadButton")} icon="material-symbols:download" />
|
||||
</download-button>
|
||||
{
|
||||
useBlob ? (
|
||||
<download-button href={href} filename={filename} class="when-js when-no-print">
|
||||
<Button title={t("global.downloadButton")} icon="material-symbols:download" />
|
||||
</download-button>
|
||||
) : (
|
||||
<a href={href} class="when-no-print">
|
||||
<Button title={t("global.downloadButton")} icon="material-symbols:download" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
{/* ------------------------------------------- JS --------------------------------------------- */}
|
||||
|
||||
|
@ -32,11 +41,11 @@ const { t } = await getI18n(Astro.locals.currentLocale);
|
|||
elem.addEventListener("click", async () => {
|
||||
const res = await fetch(href);
|
||||
const blob = await res.blob();
|
||||
const blobURL = window.URL.createObjectURL(blob);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
var link = document.createElement("a");
|
||||
link.download = filename;
|
||||
link.href = blobURL;
|
||||
link.href = url;
|
||||
link.click();
|
||||
link.remove();
|
||||
});
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
|
@ -14,7 +17,19 @@ const { title, description } = Astro.props;
|
|||
<div class="error-message">
|
||||
<Icon name="material-symbols:error-outline" width={32} height={32} />
|
||||
<p class="font-xl">{title}</p>
|
||||
{description && <p>{description}</p>}
|
||||
{
|
||||
description ? (
|
||||
<p>{description}</p>
|
||||
) : (
|
||||
<p>
|
||||
Please contact{" "}
|
||||
<a href={getLocalizedUrl("/discord")} target="_blank">
|
||||
website technical administrator
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
---
|
||||
import { AttributeTypes, type EndpointAttribute } from "src/shared/payload/payload-sdk";
|
||||
import InlineMetadata from "./InlineMetadata.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import ErrorMessage from "./ErrorMessage.astro";
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
import { AttributeTypes } from "src/shared/payload/constants";
|
||||
import type { EndpointAttribute } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
attributes: (EndpointAttribute | Attribute)[];
|
||||
|
@ -67,12 +68,7 @@ const { getLocalizedMatch, getLocalizedUrl, formatNumber } = await getI18n(
|
|||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<ErrorMessage
|
||||
title={`Unknown attribute type: ${type}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
);
|
||||
return <ErrorMessage title={`Unknown attribute type: ${type}`} />;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import type { EndpointCredit } from "src/shared/payload/payload-sdk";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import InlineMetadata from "./InlineMetadata.astro";
|
||||
import type { EndpointCredit } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
credits: EndpointCredit[];
|
||||
|
@ -23,17 +23,15 @@ const { getLocalizedMatch } = await getI18n(Astro.locals.currentLocale);
|
|||
/>
|
||||
))
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: grid;
|
||||
gap: 2em;
|
||||
|
||||
@media (max-width: 35rem) {
|
||||
gap: 3em;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
---
|
||||
import MasoActor from "components/Maso/MasoActor.astro";
|
||||
import Tooltip from "components/Tooltip.astro";
|
||||
import Button from "components/Button.astro";
|
||||
import { formatLocale } from "src/utils/format";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
|
||||
interface Props {
|
||||
currentLanguage: string;
|
||||
currentSourceLanguage: string;
|
||||
getPartialUrl: (locale: string) => string;
|
||||
availableLanguages: string[];
|
||||
}
|
||||
|
||||
const { currentLanguage, currentSourceLanguage, getPartialUrl, availableLanguages } = Astro.props;
|
||||
const { t } = await getI18n(Astro.locals.currentLocale);
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<div id="lang-selector" class="when-js when-no-print">
|
||||
<Tooltip trigger="click">
|
||||
<Button
|
||||
icon="material-symbols:translate"
|
||||
title={`${currentLanguage !== currentSourceLanguage ? `${currentSourceLanguage.toUpperCase()} → ` : ""}${currentLanguage.toUpperCase()}`}
|
||||
/>
|
||||
|
||||
<div id="tooltip-content" slot="tooltip-content">
|
||||
{
|
||||
availableLanguages.map((id) => (
|
||||
<MasoActor href={getPartialUrl(id)}>
|
||||
<p class:list={{ current: id === currentLanguage, "pressable-link": true }}>
|
||||
{formatLocale(id)}
|
||||
</p>
|
||||
</MasoActor>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<p>
|
||||
{
|
||||
t("global.languageOverride.availableLanguages", {
|
||||
count: availableLanguages.length,
|
||||
})
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
#lang-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
|
||||
#tooltip-content {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
|
||||
& .current {
|
||||
color: var(--color-base-750);
|
||||
text-decoration: underline 0.08em var(--color-base-650);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,11 +1,4 @@
|
|||
---
|
||||
import {
|
||||
type EndpointCredit,
|
||||
type EndpointImage,
|
||||
type EndpointMediaThumbnail,
|
||||
type EndpointScanImage,
|
||||
type RichTextContent,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import Credits from "./Credits.astro";
|
||||
import DownloadButton from "./DownloadButton.astro";
|
||||
import AppLayoutTitle from "./AppLayout/components/AppLayoutTitle.astro";
|
||||
|
@ -14,11 +7,18 @@ import Attributes from "./Attributes.astro";
|
|||
import { sizesToSrcset } from "src/utils/img";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import RichText from "./RichText/RichText.astro";
|
||||
import type {
|
||||
EndpointImage,
|
||||
EndpointScanImage,
|
||||
EndpointPayloadImage,
|
||||
EndpointCredit,
|
||||
} from "src/shared/payload/endpoint-types";
|
||||
import type { RichTextContent } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
previousImageHref?: string | undefined;
|
||||
nextImageHref?: string | undefined;
|
||||
image: EndpointImage | EndpointScanImage | EndpointMediaThumbnail;
|
||||
image: EndpointImage | EndpointScanImage | EndpointPayloadImage;
|
||||
pretitle?: string | undefined;
|
||||
title: string;
|
||||
subtitle?: string | undefined;
|
||||
|
@ -59,8 +59,7 @@ const hasNavigation = previousImageHref || nextImageHref;
|
|||
<a
|
||||
id="previous-button"
|
||||
class:list={{ hidden: !previousImageHref, pressable: true }}
|
||||
href={previousImageHref}
|
||||
data-astro-history="replace">
|
||||
href={previousImageHref}>
|
||||
<Icon name="material-symbols:chevron-left" />
|
||||
</a>
|
||||
)
|
||||
|
@ -81,8 +80,7 @@ const hasNavigation = previousImageHref || nextImageHref;
|
|||
<a
|
||||
id="next-button"
|
||||
class:list={{ hidden: !nextImageHref, pressable: true }}
|
||||
href={nextImageHref}
|
||||
data-astro-history="replace">
|
||||
href={nextImageHref}>
|
||||
<Icon name="material-symbols:chevron-right" />
|
||||
</a>
|
||||
)
|
||||
|
@ -110,7 +108,7 @@ const hasNavigation = previousImageHref || nextImageHref;
|
|||
{attributes.length > 0 && <Attributes attributes={attributes} />}
|
||||
{credits.length > 0 && <Credits credits={credits} />}
|
||||
{metaAttributes.length > 0 && <Attributes attributes={metaAttributes} />}
|
||||
{filename && <DownloadButton href={url} filename={filename} />}
|
||||
{filename && <DownloadButton href={url} filename={filename} useBlob />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -18,8 +18,10 @@ const { href, method = "get", class: className, id } = Astro.props;
|
|||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
maso-actor {
|
||||
cursor: pointer;
|
||||
@media (scripting: enabled) {
|
||||
maso-actor {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
import TitleIcon from "./TitleIcon.astro";
|
||||
import { isExternalLink } from "src/utils/urls";
|
||||
|
||||
interface Props extends Attribute {}
|
||||
|
||||
|
@ -15,15 +16,23 @@ if (values.length === 0) return;
|
|||
<TitleIcon title={title} icon={icon} lang={titleLang} />
|
||||
<div id="values" class:list={{ "with-border": withBorder }}>
|
||||
{
|
||||
values.map(({ name, href, lang }) =>
|
||||
href ? (
|
||||
<a class="pressable" href={href} lang={lang}>
|
||||
{name}
|
||||
</a>
|
||||
) : (
|
||||
<div lang={lang}>{name}</div>
|
||||
)
|
||||
)
|
||||
values.map(({ name, href, lang }) => {
|
||||
if (!href) {
|
||||
return <div lang={lang}>{name}</div>;
|
||||
} else {
|
||||
const newTab = isExternalLink(href);
|
||||
return (
|
||||
<a
|
||||
class="pressable"
|
||||
href={href}
|
||||
lang={lang}
|
||||
target={newTab ? "_blank" : undefined}
|
||||
rel={newTab ? "noopener noreferrer" : undefined}>
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -41,7 +50,7 @@ if (values.length === 0) return;
|
|||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
place-items: center;
|
||||
translate: 0px 3px;
|
||||
translate: 0px 2px;
|
||||
|
||||
&.with-border {
|
||||
& > div,
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointAudio } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointAudioPreview } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
audio: EndpointAudio;
|
||||
audio: EndpointAudioPreview;
|
||||
}
|
||||
|
||||
const { getLocalizedMatch, getLocalizedUrl, t, formatDuration } = await getI18n(
|
||||
|
@ -41,6 +41,6 @@ const attributesWithMeta = [
|
|||
href={getLocalizedUrl(`/audios/${id}`)}
|
||||
attributes={attributesWithMeta}
|
||||
icon="material-symbols:music-note"
|
||||
iconHoverLabel={t("global.previewTypes.audio")}
|
||||
iconHoverLabel={t("global.collections.audios", { count: 1 })}
|
||||
smallTitle={title === filename}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointChronologyEvent } from "src/shared/payload/endpoint-types";
|
||||
import { formatRichTextToString, formatTimelineDateToId } from "src/utils/format";
|
||||
|
||||
interface Props {
|
||||
event: EndpointChronologyEvent["events"][number];
|
||||
date: EndpointChronologyEvent["date"];
|
||||
}
|
||||
|
||||
const { getLocalizedUrl, getLocalizedMatch, t, formatTimelineDate } = await getI18n(
|
||||
Astro.locals.currentLocale
|
||||
);
|
||||
|
||||
const {
|
||||
date,
|
||||
event: { translations },
|
||||
} = Astro.props;
|
||||
|
||||
const { title, description, language } = getLocalizedMatch(translations);
|
||||
|
||||
const getTruncatedText = () => {
|
||||
const getTextContent = () => {
|
||||
if (title) return title;
|
||||
if (description) return formatRichTextToString(description);
|
||||
return "";
|
||||
};
|
||||
|
||||
const text = getTextContent();
|
||||
const limit = 45;
|
||||
const truncationMark = "...";
|
||||
if (text.length < limit) return text;
|
||||
return text.substring(0, limit - truncationMark.length) + truncationMark;
|
||||
};
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<GenericPreview
|
||||
lang={language}
|
||||
pretitle={formatTimelineDate(date)}
|
||||
title={getTruncatedText()}
|
||||
href={getLocalizedUrl(`/timeline#${formatTimelineDateToId(date)}`)}
|
||||
icon="material-symbols:calendar-month"
|
||||
iconHoverLabel={t("global.collections.chronologyEvents")}
|
||||
smallTitle
|
||||
/>
|
|
@ -1,16 +1,16 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointCollectible } from "src/shared/payload/payload-sdk";
|
||||
import { formatPriceForPostProcessing } from "src/middleware/postProcessing";
|
||||
import type { EndpointCollectiblePreview } from "src/shared/payload/endpoint-types";
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
import { convert } from "src/utils/currencies";
|
||||
import { formatLocale } from "src/utils/format";
|
||||
|
||||
interface Props {
|
||||
collectible: EndpointCollectible;
|
||||
collectible: EndpointCollectiblePreview;
|
||||
}
|
||||
|
||||
const { getLocalizedMatch, getLocalizedUrl, t, formatPrice, formatDate } = await getI18n(
|
||||
const { getLocalizedMatch, getLocalizedUrl, t, formatDate } = await getI18n(
|
||||
Astro.locals.currentLocale
|
||||
);
|
||||
|
||||
|
@ -41,18 +41,13 @@ if (releaseDate) {
|
|||
}
|
||||
|
||||
if (price) {
|
||||
const preferredCurrency = Astro.locals.currentCurrency;
|
||||
|
||||
const convertedPrice = {
|
||||
amount: convert(price.currency, preferredCurrency, price.amount),
|
||||
currency: preferredCurrency,
|
||||
};
|
||||
|
||||
additionalAttributes.push({
|
||||
title: t("collectibles.price"),
|
||||
icon: "material-symbols:sell",
|
||||
values: [
|
||||
{ name: price.amount === 0 ? t("collectibles.price.free") : formatPrice(convertedPrice) },
|
||||
{
|
||||
name: formatPriceForPostProcessing(price, "short"),
|
||||
},
|
||||
],
|
||||
withBorder: false,
|
||||
});
|
||||
|
@ -70,6 +65,6 @@ if (price) {
|
|||
href={getLocalizedUrl(`/collectibles/${slug}`)}
|
||||
attributes={[...attributes, ...additionalAttributes]}
|
||||
icon="material-symbols:category"
|
||||
iconHoverLabel={t("global.previewTypes.collectible")}
|
||||
iconHoverLabel={t("global.collections.collectibles", { count: 1 })}
|
||||
disableRoundedTop
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointFilePreview } from "src/shared/payload/endpoint-types";
|
||||
import { getFileIcon } from "src/utils/attributes";
|
||||
|
||||
interface Props {
|
||||
file: EndpointFilePreview;
|
||||
}
|
||||
|
||||
const { getLocalizedMatch, getLocalizedUrl, t, formatFilesize } = await getI18n(
|
||||
Astro.locals.currentLocale
|
||||
);
|
||||
|
||||
const {
|
||||
file: { id, translations, attributes, filename, thumbnail, mimeType, filesize },
|
||||
} = Astro.props;
|
||||
|
||||
const { pretitle, title, subtitle, language } =
|
||||
translations.length > 0
|
||||
? getLocalizedMatch(translations)
|
||||
: { pretitle: undefined, title: filename, subtitle: undefined, language: undefined };
|
||||
|
||||
const hasTitle = title !== filename;
|
||||
|
||||
const attributesWithMeta = [
|
||||
...attributes,
|
||||
...(hasTitle
|
||||
? [
|
||||
{
|
||||
title: t("global.media.attributes.filename"),
|
||||
icon: "material-symbols:unknown-document",
|
||||
values: [{ name: filename }],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t("global.media.attributes.filesize"),
|
||||
icon: "material-symbols:hard-drive",
|
||||
values: [{ name: formatFilesize(filesize) }],
|
||||
},
|
||||
];
|
||||
|
||||
const getFileTypeLabel = (): string => {
|
||||
switch (mimeType) {
|
||||
case "application/zip":
|
||||
return t("global.previewTypes.zip");
|
||||
|
||||
case "application/pdf":
|
||||
return t("global.previewTypes.pdf");
|
||||
|
||||
default:
|
||||
return mimeType;
|
||||
}
|
||||
};
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<GenericPreview
|
||||
pretitle={pretitle}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
lang={language}
|
||||
thumbnail={thumbnail}
|
||||
href={getLocalizedUrl(`/files/${id}`)}
|
||||
attributes={attributesWithMeta}
|
||||
icon={getFileIcon(mimeType)}
|
||||
iconHoverLabel={getFileTypeLabel()}
|
||||
smallTitle={!hasTitle}
|
||||
/>
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import { Collections } from "src/shared/payload/constants";
|
||||
import type { EndpointFolder, EndpointFolderPreview } from "src/shared/payload/endpoint-types";
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
|
||||
interface Props {
|
||||
folder: EndpointFolder | EndpointFolderPreview;
|
||||
}
|
||||
|
||||
const { getLocalizedUrl, getLocalizedMatch, t } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const { folder } = Astro.props;
|
||||
|
||||
const { language, title } = getLocalizedMatch(folder.translations);
|
||||
|
||||
const attributes: Attribute[] = [];
|
||||
|
||||
if ("files" in folder) {
|
||||
const { backlinks, files, sections } = folder;
|
||||
|
||||
const fileCount = files.length;
|
||||
|
||||
const subfolderCount =
|
||||
sections.type === "single"
|
||||
? sections.subfolders.length
|
||||
: sections.sections.reduce((acc, section) => acc + section.subfolders.length, 0);
|
||||
|
||||
attributes.push({
|
||||
icon: "material-symbols:box",
|
||||
title: t("global.folders.attributes.content.label"),
|
||||
values: [{ name: t("global.folders.attributes.content.value", { fileCount, subfolderCount }) }],
|
||||
});
|
||||
|
||||
attributes.push({
|
||||
icon: "material-symbols:keyboard-return",
|
||||
title: t("global.folders.attributes.parent"),
|
||||
values: backlinks.flatMap((link) => {
|
||||
if (link.type !== Collections.Folders) return [];
|
||||
const name = getLocalizedMatch(link.value.translations).title;
|
||||
return { name };
|
||||
}),
|
||||
});
|
||||
}
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<GenericPreview
|
||||
title={title}
|
||||
lang={language}
|
||||
href={getLocalizedUrl(`/folders/${folder.slug}`)}
|
||||
attributes={attributes}
|
||||
icon="material-symbols:folder-open"
|
||||
iconHoverLabel={t("global.collections.folders", { count: 1 })}
|
||||
smallTitle
|
||||
/>
|
|
@ -1,11 +1,4 @@
|
|||
---
|
||||
import {
|
||||
AttributeTypes,
|
||||
type EndpointAttribute,
|
||||
type EndpointImage,
|
||||
type EndpointMediaThumbnail,
|
||||
type EndpointScanImage,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import Card from "components/Card.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import type { ComponentProps } from "astro/types";
|
||||
|
@ -13,9 +6,16 @@ import { getI18n } from "src/i18n/i18n";
|
|||
import InlineAttributes from "components/InlineAttributes.astro";
|
||||
import { sizesToSrcset, sizesForGridLayout } from "src/utils/img";
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
import type {
|
||||
EndpointAttribute,
|
||||
EndpointImagePreview,
|
||||
EndpointPayloadImage,
|
||||
EndpointScanImage,
|
||||
} from "src/shared/payload/endpoint-types";
|
||||
import { AttributeTypes } from "src/shared/payload/constants";
|
||||
|
||||
interface Props {
|
||||
thumbnail?: EndpointImage | EndpointMediaThumbnail | EndpointScanImage | undefined;
|
||||
thumbnail?: EndpointImagePreview | EndpointPayloadImage | EndpointScanImage | undefined;
|
||||
pretitle?: string | undefined;
|
||||
title: string;
|
||||
subtitle?: string | undefined;
|
||||
|
@ -113,8 +113,18 @@ for (const attribute of attributes) {
|
|||
<div id="footer">
|
||||
{
|
||||
smallTitle ? (
|
||||
<p class="font-l" lang={lang}>
|
||||
{title}
|
||||
<p lang={lang}>
|
||||
{pretitle && (
|
||||
<span id="pretitle" class="font-s">
|
||||
{pretitle}
|
||||
</span>
|
||||
)}
|
||||
<span class="font-l">{title}</span>
|
||||
{subtitle && (
|
||||
<span id="subtitle" class="font-m">
|
||||
{subtitle}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p lang={lang}>
|
||||
|
@ -137,9 +147,7 @@ for (const attribute of attributes) {
|
|||
clippedAttributes.length > 0 && (
|
||||
<>
|
||||
{subtitle && <hr />}
|
||||
<div id="tags">
|
||||
<InlineAttributes attributes={clippedAttributes} />
|
||||
</div>
|
||||
<InlineAttributes attributes={clippedAttributes} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -167,21 +175,21 @@ for (const attribute of attributes) {
|
|||
height: auto;
|
||||
|
||||
&.rounded-top {
|
||||
border-top-left-radius: 1em;
|
||||
border-top-right-radius: 1em;
|
||||
border-top-left-radius: 14px;
|
||||
border-top-right-radius: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
& > #icon-container {
|
||||
&.thumbnail-alt {
|
||||
margin: 0.4em;
|
||||
margin: 6px;
|
||||
margin-bottom: unset;
|
||||
aspect-ratio: 3/2;
|
||||
background-color: var(--color-elevation-2);
|
||||
color: var(--color-base-400);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
border-radius: 0.7em;
|
||||
border-radius: 8px;
|
||||
|
||||
& > svg {
|
||||
width: 64px;
|
||||
|
@ -191,18 +199,18 @@ for (const attribute of attributes) {
|
|||
|
||||
&:not(.thumbnail-alt) {
|
||||
position: absolute;
|
||||
top: 0.4em;
|
||||
left: 0.4em;
|
||||
padding: 0.5em;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
padding: 8px;
|
||||
backdrop-filter: blur(5px);
|
||||
background-color: color-mix(in srgb, var(--color-elevation-2) 60%, transparent);
|
||||
}
|
||||
|
||||
&.rounded-top {
|
||||
border-radius: 0.7em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
border-bottom-right-radius: 0.7em;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
& > #footer {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointImage } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointImagePreview } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
image: EndpointImage;
|
||||
image: EndpointImagePreview;
|
||||
}
|
||||
|
||||
const { getLocalizedMatch, getLocalizedUrl, t } = await getI18n(Astro.locals.currentLocale);
|
||||
|
@ -31,6 +31,6 @@ const { pretitle, title, subtitle, language } =
|
|||
href={getLocalizedUrl(`/images/${id}`)}
|
||||
attributes={attributes}
|
||||
icon="material-symbols:imagesmode"
|
||||
iconHoverLabel={t("global.previewTypes.image")}
|
||||
iconHoverLabel={t("global.collections.images", { count: 1 })}
|
||||
smallTitle={title === filename}
|
||||
/>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointPage } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointPagePreview } from "src/shared/payload/endpoint-types";
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
|
||||
interface Props {
|
||||
page: EndpointPage;
|
||||
page: EndpointPagePreview;
|
||||
}
|
||||
|
||||
const { getLocalizedMatch, getLocalizedUrl, t, formatDate } = await getI18n(
|
||||
|
@ -39,5 +39,5 @@ const metaAttributes: Attribute[] = [
|
|||
href={getLocalizedUrl(`/pages/${slug}`)}
|
||||
attributes={[...attributes, ...metaAttributes]}
|
||||
icon="material-symbols:docs"
|
||||
iconHoverLabel={t("global.previewTypes.page")}
|
||||
iconHoverLabel={t("global.collections.pages", { count: 1 })}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointRecorder } from "src/shared/payload/endpoint-types";
|
||||
import type { Attribute } from "src/utils/attributes";
|
||||
import { formatLocale } from "src/utils/format";
|
||||
|
||||
interface Props {
|
||||
recorder: EndpointRecorder;
|
||||
}
|
||||
|
||||
const { getLocalizedUrl, t } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const {
|
||||
recorder: { id, languages, username, avatar },
|
||||
} = Astro.props;
|
||||
|
||||
const attributes: Attribute[] = [
|
||||
{
|
||||
icon: "material-symbols:translate",
|
||||
title: t("collectibles.languages"),
|
||||
values: languages.map((id) => ({ name: formatLocale(id) })),
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<GenericPreview
|
||||
title={username}
|
||||
thumbnail={avatar}
|
||||
href={getLocalizedUrl(`/recorders/${id}`)}
|
||||
attributes={attributes}
|
||||
icon="material-symbols:person"
|
||||
iconHoverLabel={t("global.collections.recorders", { count: 1 })}
|
||||
/>
|
|
@ -1,10 +1,10 @@
|
|||
---
|
||||
import GenericPreview from "components/Previews/GenericPreview.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointVideo } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointVideoPreview } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
video: EndpointVideo;
|
||||
video: EndpointVideoPreview;
|
||||
}
|
||||
|
||||
const { getLocalizedMatch, getLocalizedUrl, t, formatDuration } = await getI18n(
|
||||
|
@ -41,6 +41,6 @@ const attributesWithMeta = [
|
|||
href={getLocalizedUrl(`/videos/${id}`)}
|
||||
attributes={attributesWithMeta}
|
||||
icon="material-symbols:smart-display"
|
||||
iconHoverLabel={t("global.previewTypes.video")}
|
||||
iconHoverLabel={t("global.collections.videos", { count: 1 })}
|
||||
smallTitle={title === filename}
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointRelation } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
relation: EndpointRelation;
|
||||
}
|
||||
|
||||
const { relation } = Astro.props;
|
||||
const { formatEndpointRelation } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const {
|
||||
href,
|
||||
typeLabel,
|
||||
label,
|
||||
target = undefined,
|
||||
rel = undefined,
|
||||
lang,
|
||||
} = formatEndpointRelation(relation);
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<a href={href} target={target} rel={rel}>
|
||||
<p>
|
||||
<span id="type" class="font-xs">{typeLabel}</span><span id="label" lang={lang}>{label}</span>
|
||||
</p>
|
||||
</a>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
a {
|
||||
border-radius: 16px;
|
||||
padding: 4px 10px;
|
||||
margin: -4px -10px;
|
||||
|
||||
& > p {
|
||||
display: flex;
|
||||
place-items: start;
|
||||
gap: 0.1em 0.3em;
|
||||
|
||||
& > #label {
|
||||
text-decoration: underline dotted 0.1em;
|
||||
text-decoration-color: transparent;
|
||||
|
||||
transition-duration: 150ms;
|
||||
transition-property: text-decoration-color, color;
|
||||
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
& > #type {
|
||||
background-color: var(--color-base-300);
|
||||
border-radius: 9999px;
|
||||
padding: 0.3em 0.6em;
|
||||
flex-shrink: 0;
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover > p > #label,
|
||||
&:focus-visible > p > #label {
|
||||
color: var(--color-base-750);
|
||||
text-decoration-color: var(--color-base-650);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-width: 1.5 px;
|
||||
}
|
||||
|
||||
&:active > p > #label {
|
||||
color: var(--color-base-650);
|
||||
text-decoration-color: var(--color-base-550);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,140 @@
|
|||
---
|
||||
import ChronologyEventPreview from "components/Previews/ChronologyEventPreview.astro";
|
||||
import CollectiblePreview from "components/Previews/CollectiblePreview.astro";
|
||||
import FolderPreview from "components/Previews/FolderPreview.astro";
|
||||
import { Collections } from "src/shared/payload/constants";
|
||||
import type {
|
||||
EndpointChronologyEvent,
|
||||
EndpointCollectiblePreview,
|
||||
EndpointFolderPreview,
|
||||
EndpointRelation,
|
||||
} from "src/shared/payload/endpoint-types";
|
||||
import ReturnToParentCard from "./AppLayout/components/Topbar/components/ReturnToParentCard.astro";
|
||||
import AppLayoutTitle from "./AppLayout/components/AppLayoutTitle.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import AppLayout from "./AppLayout/AppLayout.astro";
|
||||
|
||||
interface Props {
|
||||
parentPage: EndpointRelation;
|
||||
backlinks: EndpointRelation[];
|
||||
}
|
||||
|
||||
const { backlinks, parentPage } = Astro.props;
|
||||
const { formatEndpointRelation, t } = await getI18n(Astro.locals.currentLocale);
|
||||
const { label } = formatEndpointRelation(parentPage);
|
||||
|
||||
const collectibles: EndpointCollectiblePreview[] = [];
|
||||
const folders: EndpointFolderPreview[] = [];
|
||||
const events: EndpointChronologyEvent[] = [];
|
||||
|
||||
backlinks.forEach((relation) => {
|
||||
switch (relation.type) {
|
||||
case Collections.Collectibles:
|
||||
collectibles.push(relation.value);
|
||||
break;
|
||||
|
||||
case Collections.Folders:
|
||||
folders.push(relation.value);
|
||||
break;
|
||||
|
||||
case Collections.ChronologyEvents:
|
||||
events.push(relation.value);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<AppLayout class="app">
|
||||
<AppLayoutTitle title={t("global.relationPage.title")} />
|
||||
|
||||
<ReturnToParentCard parent={parentPage} />
|
||||
|
||||
{
|
||||
collectibles.length > 0 && (
|
||||
<section>
|
||||
<h2 class="font-3xl font-serif">
|
||||
{t("global.collections.collectibles", { count: collectibles.length })}
|
||||
</h2>
|
||||
<p>{t("global.relationPage.collectibles", { title: label, count: folders.length })}</p>
|
||||
<div class="grid">
|
||||
{collectibles.map((collectible) => (
|
||||
<CollectiblePreview collectible={collectible} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
folders.length > 0 && (
|
||||
<section>
|
||||
<h2 class="font-3xl font-serif">
|
||||
{t("global.collections.folders", { count: folders.length })}
|
||||
</h2>
|
||||
<p>{t("global.relationPage.folders", { title: label, count: folders.length })}</p>
|
||||
<div class="grid">
|
||||
{folders.map((folder) => (
|
||||
<FolderPreview folder={folder} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
events.length > 0 && (
|
||||
<section>
|
||||
<h2 class="font-3xl font-serif">{t("global.collections.chronologyEvents")}</h2>
|
||||
<p>{t("global.relationPage.timelineEvents", { title: label, count: folders.length })}</p>
|
||||
<div class="grid">
|
||||
{events.map(({ date, events }) =>
|
||||
events.map((event) => <ChronologyEventPreview date={date} event={event} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</AppLayout>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: clamp(6px, 2vmin, 16px);
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
row-gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 24rem) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
margin-block: 3em;
|
||||
|
||||
& > h2 {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
import type { RichTextContent } from "src/shared/payload/payload-sdk";
|
||||
import RTNode from "./components/RTNode.astro";
|
||||
import RTProse from "./components/RTProse.astro";
|
||||
import { type RichTextContext } from "src/utils/richText";
|
||||
import ConditionalWrapper from "components/ConditionalWrapper.astro";
|
||||
import type { RichTextContent } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
content: RichTextContent;
|
||||
|
|
|
@ -2,14 +2,14 @@
|
|||
import type { RichTextContext } from "src/utils/richText";
|
||||
import RTSection from "./components/RTSection.astro";
|
||||
import RTTranscript from "./components/RTTranscript.astro";
|
||||
import {
|
||||
isBlockNodeBreakBlock,
|
||||
isBlockNodeSectionBlock,
|
||||
isBlockNodeTranscriptBlock,
|
||||
type RichTextBlockNode,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import RTBreak from "./components/RTBreak.astro";
|
||||
import {
|
||||
type RichTextBlockNode,
|
||||
isBlockNodeSectionBlock,
|
||||
isBlockNodeTranscriptBlock,
|
||||
isBlockNodeBreakBlock,
|
||||
} from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextBlockNode;
|
||||
|
@ -29,9 +29,6 @@ const { node, context } = Astro.props;
|
|||
) : isBlockNodeBreakBlock(node) ? (
|
||||
<RTBreak node={node} context={context} />
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown block type: ${node.fields.blockType}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown block type: ${node.fields.blockType}`} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
---
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import { BreakBlockType, type RichTextBreakBlock } from "src/shared/payload/payload-sdk";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import { BreakBlockType } from "src/shared/payload/constants";
|
||||
import type { RichTextBreakBlock } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextBreakBlock;
|
||||
|
@ -29,10 +30,7 @@ const { node } = Astro.props;
|
|||
) : node.fields.type === BreakBlockType.solidLine ? (
|
||||
<hr id={node.anchorHash} class="solid" />
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown break block type: ${node.fields.type}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown break block type: ${node.fields.type}`} />
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import HeaderTitle from "components/HeaderTitle.astro";
|
||||
import RichText from "components/RichText/RichText.astro";
|
||||
import type { RichTextSectionBlock } from "src/shared/payload/payload-sdk";
|
||||
import type { RichTextSectionBlock } from "src/shared/payload/rich-text";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import type { RichTextTranscriptBlock } from "src/shared/payload/payload-sdk";
|
||||
import Block from "components/Blocks/Block.astro";
|
||||
import type { RichTextTranscriptBlock } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextTranscriptBlock;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import type { RichTextLinebreakNode } from "src/shared/payload/rich-text";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import type { RichTextLinebreakNode } from "src/shared/payload/payload-sdk";
|
||||
|
||||
interface Props {
|
||||
node: RichTextLinebreakNode;
|
||||
|
|
|
@ -3,12 +3,12 @@ import type { RichTextContext } from "src/utils/richText";
|
|||
import RTNode from "../RTNode.astro";
|
||||
import RTCustomLink from "./components/RTCustomLink.astro";
|
||||
import RTInternalLink from "./components/RTInternalLink.astro";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import {
|
||||
isLinkNodeCustomLinkNode,
|
||||
isLinkNodeInternalLinkNode,
|
||||
type RichTextLinkNode,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
} from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextLinkNode;
|
||||
|
@ -34,9 +34,6 @@ const { node, context } = Astro.props;
|
|||
))}
|
||||
</RTInternalLink>
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown link type: ${node.fields.linkType}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown link type: ${node.fields.linkType}`} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import { Collections } from "src/shared/payload/payload-sdk";
|
||||
import { Collections } from "src/shared/payload/constants";
|
||||
|
||||
interface Props {
|
||||
doc: {
|
||||
|
@ -30,9 +30,6 @@ const { getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
|
|||
<slot />
|
||||
</a>
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown internal link: ${doc.relationTo}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown internal link: ${doc.relationTo}`} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,13 +2,13 @@
|
|||
import type { RichTextContext } from "src/utils/richText";
|
||||
import RTBasicListItem from "./components/RTBasicListItem.astro";
|
||||
import RTCheckListItem from "./components/RTCheckListItem.astro";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import {
|
||||
type RichTextListNode,
|
||||
isListNodeNumberListNode,
|
||||
isListNodeBulletListNode,
|
||||
isListNodeCheckListNode,
|
||||
isListNodeNumberListNode,
|
||||
type RichTextListNode,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
} from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextListNode;
|
||||
|
@ -40,9 +40,6 @@ const { node, context } = Astro.props;
|
|||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown list link: ${node.listType}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown list link: ${node.listType}`} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,21 +5,21 @@ import RTText from "./RTText/RTText.astro";
|
|||
import RTLink from "./RTLink/RTLink.astro";
|
||||
import RTBlock from "./RTBlock/RTBlock.astro";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import {
|
||||
isNodeBlockNode,
|
||||
isNodeLinebreakNode,
|
||||
isNodeLinkNode,
|
||||
isNodeListNode,
|
||||
isNodeParagraphNode,
|
||||
isNodeTabNode,
|
||||
isNodeTextNode,
|
||||
isNodeUploadNode,
|
||||
type RichTextNode,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import RTTab from "./RTTab.astro";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import RTLinebreak from "./RTLinebreak.astro";
|
||||
import RTUpload from "./RTUpload/RTUpload.astro";
|
||||
import {
|
||||
type RichTextNode,
|
||||
isNodeParagraphNode,
|
||||
isNodeListNode,
|
||||
isNodeTextNode,
|
||||
isNodeLinebreakNode,
|
||||
isNodeLinkNode,
|
||||
isNodeBlockNode,
|
||||
isNodeTabNode,
|
||||
isNodeUploadNode,
|
||||
} from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextNode;
|
||||
|
@ -49,9 +49,6 @@ const { node, context } = Astro.props;
|
|||
) : isNodeUploadNode(node) ? (
|
||||
<RTUpload node={node} context={context} />
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown node type: ${node.type}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown node type: ${node.type}`} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import RTNode from "./RTNode.astro";
|
||||
import type { RichTextParagraphNode } from "src/shared/payload/payload-sdk";
|
||||
import type { RichTextParagraphNode } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextParagraphNode;
|
||||
|
|
|
@ -8,7 +8,7 @@ import RTSubscript from "./components/RTSubscript.astro";
|
|||
import RTSuperscript from "./components/RTSuperscript.astro";
|
||||
import RTInlineCode from "./components/RTInlineCode.astro";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import type { RichTextTextNode } from "src/shared/payload/payload-sdk";
|
||||
import type { RichTextTextNode } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextTextNode;
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
---
|
||||
import {
|
||||
isUploadNodeAudioNode,
|
||||
isUploadNodeImageNode,
|
||||
isUploadNodeVideoNode,
|
||||
type RichTextUploadNode,
|
||||
} from "src/shared/payload/payload-sdk";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import RTImage from "./components/RTImage.astro";
|
||||
import ErrorMessage from "components/ErrorMessage.astro";
|
||||
import RTAudio from "./components/RTAudio.astro";
|
||||
import RTVideo from "./components/RTVideo.astro";
|
||||
import {
|
||||
type RichTextUploadNode,
|
||||
isUploadNodeImageNode,
|
||||
isUploadNodeAudioNode,
|
||||
isUploadNodeVideoNode,
|
||||
} from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextUploadNode;
|
||||
|
@ -29,9 +29,6 @@ const { node, context } = Astro.props;
|
|||
) : isUploadNodeVideoNode(node) ? (
|
||||
<RTVideo node={node} context={context} />
|
||||
) : (
|
||||
<ErrorMessage
|
||||
title={`Unknown upload collection: ${node.relationTo}`}
|
||||
description="Please contact website technical administrator."
|
||||
/>
|
||||
<ErrorMessage title={`Unknown upload collection: ${node.relationTo}`} />
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
import { type RichTextUploadAudioNode } from "src/shared/payload/payload-sdk";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import AudioPlayer from "components/AudioPlayer.astro";
|
||||
import HeaderTitle from "components/HeaderTitle.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import OpenMediaPageButton from "./OpenMediaPageButton.astro";
|
||||
import type { RichTextUploadAudioNode } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextUploadAudioNode;
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
---
|
||||
import { type RichTextUploadImageNode } from "src/shared/payload/payload-sdk";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import OpenMediaPageButton from "./OpenMediaPageButton.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import HeaderTitle from "components/HeaderTitle.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { sizesToSrcset } from "src/utils/img";
|
||||
import type { RichTextUploadImageNode } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextUploadImageNode;
|
||||
|
@ -47,7 +46,6 @@ const mediaPage = getLocalizedUrl(`/images/${id}`);
|
|||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
<OpenMediaPageButton url={mediaPage} />
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
@ -55,11 +53,13 @@ const mediaPage = getLocalizedUrl(`/images/${id}`);
|
|||
<style>
|
||||
div {
|
||||
margin-block: 4em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
line-height: 0;
|
||||
margin-bottom: 0.5em;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 5px 20px -10px var(--color-shadow-0);
|
||||
border-radius: 16px;
|
||||
|
@ -71,7 +71,6 @@ const mediaPage = getLocalizedUrl(`/images/${id}`);
|
|||
&:hover,
|
||||
&:focus-visible {
|
||||
scale: 102%;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
max-height: 70vh;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
---
|
||||
import { type RichTextUploadVideoNode } from "src/shared/payload/payload-sdk";
|
||||
import type { RichTextContext } from "src/utils/richText";
|
||||
import VideoPlayer from "components/VideoPlayer.astro";
|
||||
import OpenMediaPageButton from "./OpenMediaPageButton.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import HeaderTitle from "components/HeaderTitle.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import type { RichTextUploadVideoNode } from "src/shared/payload/rich-text";
|
||||
|
||||
interface Props {
|
||||
node: RichTextUploadVideoNode;
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { EndpointSource } from "src/shared/payload/payload-sdk";
|
||||
|
||||
interface Props {
|
||||
source: EndpointSource;
|
||||
}
|
||||
|
||||
const { source } = Astro.props;
|
||||
const { formatEndpointSource } = await getI18n(Astro.locals.currentLocale);
|
||||
|
||||
const {
|
||||
href,
|
||||
typeLabel,
|
||||
label,
|
||||
target = undefined,
|
||||
rel = undefined,
|
||||
lang,
|
||||
} = formatEndpointSource(source);
|
||||
---
|
||||
|
||||
{/* ------------------------------------------- HTML ------------------------------------------- */}
|
||||
|
||||
<a href={href} target={target} rel={rel}>
|
||||
<div class="font-xs">{typeLabel}</div><p lang={lang}>{label}</p>
|
||||
</a>
|
||||
|
||||
{/* ------------------------------------------- CSS -------------------------------------------- */}
|
||||
|
||||
<style>
|
||||
a {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
place-items: center;
|
||||
gap: 0.1em 0.3em;
|
||||
|
||||
& > p {
|
||||
text-decoration: underline dotted 0.1em;
|
||||
text-decoration-color: transparent;
|
||||
|
||||
transition-duration: 150ms;
|
||||
transition-property: text-decoration-color, color;
|
||||
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
border-radius: 16px;
|
||||
padding: 4px 10px;
|
||||
margin: -4px -10px;
|
||||
|
||||
&:hover > p,
|
||||
&:focus-visible > p {
|
||||
color: var(--color-base-750);
|
||||
text-decoration-color: var(--color-base-650);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-width: 1.5 px;
|
||||
}
|
||||
|
||||
&:active > p {
|
||||
color: var(--color-base-650);
|
||||
text-decoration-color: var(--color-base-550);
|
||||
}
|
||||
|
||||
& > div {
|
||||
background-color: var(--color-base-300);
|
||||
border-radius: 9999px;
|
||||
padding: 0.3em 0.6em;
|
||||
flex-shrink: 0;
|
||||
margin-left: -0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,8 @@
|
|||
---
|
||||
import type { TableOfContentEntry } from "src/shared/payload/payload-sdk";
|
||||
import TableOfContentItem from "./components/TableOfContentItem.astro";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import TitleIcon from "components/TitleIcon.astro";
|
||||
import type { TableOfContentEntry } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
toc: TableOfContentEntry[];
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import type { TableOfContentEntry } from "src/shared/payload/payload-sdk";
|
||||
import type { TableOfContentEntry } from "src/shared/payload/endpoint-types";
|
||||
|
||||
interface Props {
|
||||
entry: TableOfContentEntry;
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
import type { EndpointVideo } from "src/shared/payload/payload-sdk";
|
||||
import type { EndpointVideoPreview } from "src/shared/payload/endpoint-types";
|
||||
import { formatLocale } from "src/utils/format";
|
||||
|
||||
interface Props {
|
||||
video: EndpointVideo;
|
||||
video: EndpointVideoPreview;
|
||||
class?: string | undefined;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
declare namespace App {
|
||||
interface Locals {
|
||||
currentLocale: string;
|
||||
currentTheme: "dark" | "auto" | "light";
|
||||
currentCurrency: string;
|
||||
notFound: boolean;
|
||||
pageCaching: boolean;
|
||||
}
|
||||
}
|
||||
|
|
121
src/i18n/i18n.ts
121
src/i18n/i18n.ts
|
@ -1,7 +1,13 @@
|
|||
import type { WordingKey } from "src/i18n/wordings-keys";
|
||||
import type { ChronologyEvent, EndpointSource } from "src/shared/payload/payload-sdk";
|
||||
import { cache } from "src/utils/payload";
|
||||
import { capitalize, formatInlineTitle } from "src/utils/format";
|
||||
import { contextCache } from "src/services";
|
||||
import {
|
||||
capitalize,
|
||||
formatInlineTitle,
|
||||
formatRichTextToString,
|
||||
formatTimelineDateToId,
|
||||
} from "src/utils/format";
|
||||
import type { EndpointChronologyEvent, EndpointRelation } from "src/shared/payload/endpoint-types";
|
||||
import { Collections } from "src/shared/payload/constants";
|
||||
|
||||
export const defaultLocale = "en";
|
||||
|
||||
|
@ -113,7 +119,7 @@ export const getI18n = async (locale: string) => {
|
|||
options[0]!; // We will consider that there will always be at least one option.
|
||||
|
||||
const t = (key: WordingKey, values: Record<string, any> = {}): string => {
|
||||
const wording = cache.wordings.find(({ name }) => name === key);
|
||||
const wording = contextCache.wordings.find(({ name }) => name === key);
|
||||
const fallbackString = `«${key}»`;
|
||||
|
||||
if (!wording) {
|
||||
|
@ -177,7 +183,7 @@ export const getI18n = async (locale: string) => {
|
|||
return number.toLocaleString(locale, options);
|
||||
};
|
||||
|
||||
const formatTimelineDate = ({ year, month, day }: ChronologyEvent["date"]): string => {
|
||||
const formatTimelineDate = ({ year, month, day }: EndpointChronologyEvent["date"]): string => {
|
||||
const date = new Date(0);
|
||||
date.setFullYear(year);
|
||||
if (month) date.setMonth(month - 1);
|
||||
|
@ -236,8 +242,8 @@ export const getI18n = async (locale: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const formatEndpointSource = (
|
||||
source: EndpointSource
|
||||
const formatEndpointRelation = (
|
||||
relation: EndpointRelation
|
||||
): {
|
||||
href: string;
|
||||
typeLabel: string;
|
||||
|
@ -246,98 +252,107 @@ export const getI18n = async (locale: string) => {
|
|||
target?: string;
|
||||
rel?: string;
|
||||
} => {
|
||||
switch (source.type) {
|
||||
case "url": {
|
||||
switch (relation.type) {
|
||||
case "url":
|
||||
return {
|
||||
href: source.url,
|
||||
href: relation.value.url,
|
||||
typeLabel: t("global.sources.typeLabel.url"),
|
||||
label: source.label,
|
||||
label: relation.value.label,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
};
|
||||
}
|
||||
|
||||
case "collectible": {
|
||||
const rangeLabel = (() => {
|
||||
switch (source.range?.type) {
|
||||
case Collections.Collectibles: {
|
||||
const getRangeLabel = () => {
|
||||
switch (relation.range?.type) {
|
||||
case "timestamp":
|
||||
return t("global.sources.typeLabel.collectible.range.timestamp", {
|
||||
page: source.range.timestamp,
|
||||
page: relation.range.timestamp,
|
||||
});
|
||||
|
||||
case "page":
|
||||
return t("global.sources.typeLabel.collectible.range.page", {
|
||||
page: source.range.page,
|
||||
page: relation.range.page,
|
||||
});
|
||||
|
||||
case "custom":
|
||||
return t("global.sources.typeLabel.collectible.range.custom", {
|
||||
note: getLocalizedMatch(source.range.translations).note,
|
||||
note: getLocalizedMatch(relation.range.translations).note,
|
||||
});
|
||||
|
||||
case undefined:
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const translation = getLocalizedMatch(source.collectible.translations);
|
||||
const suffix =
|
||||
relation.subpage === "scans"
|
||||
? "/scans"
|
||||
: relation.subpage === "gallery"
|
||||
? "/gallery"
|
||||
: "";
|
||||
|
||||
const translation = getLocalizedMatch(relation.value.translations);
|
||||
return {
|
||||
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}`),
|
||||
href: getLocalizedUrl(`/collectibles/${relation.value.slug}${suffix}`),
|
||||
typeLabel: t("global.sources.typeLabel.collectible"),
|
||||
label: formatInlineTitle(translation) + rangeLabel,
|
||||
label: formatInlineTitle(translation) + getRangeLabel(),
|
||||
lang: translation.language,
|
||||
};
|
||||
}
|
||||
|
||||
case "page": {
|
||||
const translation = getLocalizedMatch(source.page.translations);
|
||||
case Collections.Pages: {
|
||||
const translation = getLocalizedMatch(relation.value.translations);
|
||||
return {
|
||||
href: getLocalizedUrl(`/pages/${source.page.slug}`),
|
||||
href: getLocalizedUrl(`/pages/${relation.value.slug}`),
|
||||
typeLabel: t("global.sources.typeLabel.page"),
|
||||
label: formatInlineTitle(translation),
|
||||
lang: translation.language,
|
||||
};
|
||||
}
|
||||
|
||||
case "folder": {
|
||||
const translation = getLocalizedMatch(source.folder.translations);
|
||||
case Collections.Folders: {
|
||||
const translation = getLocalizedMatch(relation.value.translations);
|
||||
return {
|
||||
href: getLocalizedUrl(`/folders/${source.folder.slug}`),
|
||||
href: getLocalizedUrl(`/folders/${relation.value.slug}`),
|
||||
typeLabel: t("global.sources.typeLabel.folder"),
|
||||
label: getLocalizedMatch(source.folder.translations).name,
|
||||
label: formatInlineTitle(translation),
|
||||
lang: translation.language,
|
||||
};
|
||||
}
|
||||
|
||||
case "scans": {
|
||||
const translation = getLocalizedMatch(source.collectible.translations);
|
||||
case Collections.ChronologyEvents: {
|
||||
if (!relation.value.events[0]) break;
|
||||
|
||||
const translation = getLocalizedMatch(relation.value.events[0].translations);
|
||||
let label =
|
||||
translation.title ??
|
||||
(translation.description && formatRichTextToString(translation.description));
|
||||
if (!label) break;
|
||||
|
||||
return {
|
||||
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}/scans`),
|
||||
typeLabel: t("global.sources.typeLabel.scans"),
|
||||
label: formatInlineTitle(getLocalizedMatch(source.collectible.translations)),
|
||||
lang: translation.language,
|
||||
href: getLocalizedUrl(`/timeline#${formatTimelineDateToId(relation.value.date)}`),
|
||||
typeLabel: t("global.sources.typeLabel.timeline"),
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
case "gallery": {
|
||||
const translation = getLocalizedMatch(source.collectible.translations);
|
||||
return {
|
||||
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}/gallery`),
|
||||
typeLabel: t("global.sources.typeLabel.gallery"),
|
||||
label: formatInlineTitle(getLocalizedMatch(source.collectible.translations)),
|
||||
lang: translation.language,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
return {
|
||||
href: "/404",
|
||||
label: `Invalid type ${source["type"]}`,
|
||||
typeLabel: "Error",
|
||||
};
|
||||
}
|
||||
/* TODO: Handle other types of relations */
|
||||
case Collections.Audios:
|
||||
case Collections.Files:
|
||||
case Collections.Images:
|
||||
case Collections.Recorders:
|
||||
case Collections.Tags:
|
||||
case Collections.Videos:
|
||||
default:
|
||||
}
|
||||
|
||||
return {
|
||||
href: "/404",
|
||||
label: `Invalid type ${relation["type"]}`,
|
||||
typeLabel: "Error",
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -353,7 +368,7 @@ export const getI18n = async (locale: string) => {
|
|||
formatMillimeters,
|
||||
formatNumber,
|
||||
formatTimelineDate,
|
||||
formatEndpointSource,
|
||||
formatEndpointRelation,
|
||||
formatScanIndexShort,
|
||||
formatFilesize,
|
||||
};
|
||||
|
|
|
@ -85,7 +85,7 @@ export type WordingKey =
|
|||
| "global.loading"
|
||||
| "pages.tableOfContent.sceneBreak"
|
||||
| "pages.tableOfContent.break"
|
||||
| "global.languageOverride.availableLanguages"
|
||||
| "pages.credits.availableLanguages"
|
||||
| "timeline.title"
|
||||
| "timeline.description"
|
||||
| "timeline.eras.cataclysm"
|
||||
|
@ -147,4 +147,43 @@ export type WordingKey =
|
|||
| "collectibles.nature"
|
||||
| "collectibles.languages"
|
||||
| "collectibles.nature.physical"
|
||||
| "collectibles.nature.digital";
|
||||
| "collectibles.nature.digital"
|
||||
| "global.previewTypes.zip"
|
||||
| "global.previewTypes.pdf"
|
||||
| "collectibles.files"
|
||||
| "pages.credits.translationLabel"
|
||||
| "pages.credits.currentLocale"
|
||||
| "global.media.attributes.resolution"
|
||||
| "search.searchBar.placeholder"
|
||||
| "global.collections.collectibles"
|
||||
| "global.collections.pages"
|
||||
| "global.collections.folders"
|
||||
| "global.collections.audios"
|
||||
| "global.collections.videos"
|
||||
| "global.collections.images"
|
||||
| "global.collections.files"
|
||||
| "global.collections.recorders"
|
||||
| "global.collections.chronologyEvents"
|
||||
| "global.collections.all"
|
||||
| "search.resultCount"
|
||||
| "search.searchBar.submitButton.tooltip"
|
||||
| "search.noResult.title"
|
||||
| "search.noResult.message"
|
||||
| "search.collectionFilter.tooltip"
|
||||
| "paginator.goFirstPageButton"
|
||||
| "paginator.goPreviousPageButton"
|
||||
| "paginator.goNextPageButton"
|
||||
| "paginator.goLastPageButton"
|
||||
| "global.folders.attributes.content.label"
|
||||
| "global.folders.attributes.content.value"
|
||||
| "global.folders.attributes.parent"
|
||||
| "global.sources.typeLabel.timeline"
|
||||
| "global.subpageCard.message"
|
||||
| "global.subpageCard.returnButton"
|
||||
| "header.nav.parentPages.tooltip.viewAll"
|
||||
| "global.relationPage.folders"
|
||||
| "global.relationPage.collectibles"
|
||||
| "global.relationPage.timelineEvents"
|
||||
| "global.relationPage.title"
|
||||
| "pages.credits.sources";
|
||||
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" version="1.1" id="svg1" xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs id="defs1" />
|
||||
<path fill="currentColor"
|
||||
d="M 6,22 C 5.45,22 4.979,21.804 4.587,21.412 4.195,21.02 3.9993333,20.549333 4,20 V 4 C 4,3.45 4.196,2.979 4.588,2.587 4.98,2.195 5.4506667,1.9993333 6,2 h 8 l 6,6 v 12 c 0,0.55 -0.196,1.021 -0.588,1.413 C 19.02,21.805 18.549333,22.000667 18,22 Z M 13,9 h 5 L 13,4 Z m -5.8880933,9.669723 h 9.5724213 l -2.991381,-3.98851 -2.393106,3.190808 -1.7948288,-2.393105 z M 9.1061613,13.08581 c 0.332376,0 0.6150281,-0.116465 0.8479576,-0.349393 0.2329281,-0.23293 0.3491271,-0.515317 0.3485951,-0.84716 0,-0.332376 -0.116464,-0.615029 -0.3493937,-0.847957 -0.2329286,-0.232929 -0.5153151,-0.349128 -0.847159,-0.348596 -0.3323761,0 -0.6150282,0.116464 -0.8479571,0.349393 -0.232929,0.23293 -0.3491278,0.515316 -0.3485957,0.84716 0,0.332376 0.1164644,0.615028 0.3493934,0.847957 0.2329289,0.232929 0.5153154,0.349128 0.8471594,0.348596 z"
|
||||
id="path1" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" version="1.1" id="svg1" xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs id="defs1" />
|
||||
<path fill="currentColor"
|
||||
d="M 6,22 C 5.45,22 4.979,21.804 4.587,21.412 4.195,21.02 3.9993333,20.549333 4,20 V 4 C 4,3.45 4.196,2.979 4.588,2.587 4.98,2.195 5.4506667,1.9993333 6,2 h 8 l 6,6 v 12 c 0,0.55 -0.196,1.021 -0.588,1.413 C 19.02,21.805 18.549333,22.000667 18,22 Z M 13,9 h 5 L 13,4 Z m -6.299094,9.065324 h 1 v -2 h 1 c 0.283333,0 0.521,-0.096 0.713,-0.288 0.192,-0.192 0.287667,-0.429333 0.287,-0.712 v -1 c 0,-0.283334 -0.096,-0.521 -0.288,-0.713 -0.192,-0.192 -0.429333,-0.287667 -0.712,-0.287 h -2 z m 1,-3 v -1 h 1 v 1 z m 3,3 h 2 c 0.283333,0 0.520999,-0.096 0.712999,-0.288 0.192,-0.192 0.287667,-0.429333 0.287,-0.712 v -3 c 0,-0.283334 -0.096,-0.521 -0.288,-0.713 -0.192,-0.192 -0.429332,-0.287667 -0.711999,-0.287 h -2 z m 1,-1 v -3 h 1 v 3 z m 2.999999,1 h 1 v -2 h 1 v -1 h -1 v -1 h 1 v -1 h -2 z"
|
||||
id="path1" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -1,243 +1,28 @@
|
|||
import { defineMiddleware, sequence } from "astro:middleware";
|
||||
import { cache } from "src/utils/payload";
|
||||
import acceptLanguage from "accept-language";
|
||||
import type { AstroCookies } from "astro";
|
||||
import { z } from "astro:content";
|
||||
import { trackRequest, trackEvent } from "src/shared/analytics/analytics";
|
||||
import { defaultLocale } from "src/i18n/i18n";
|
||||
|
||||
const ninetyDaysInSeconds = 60 * 60 * 24 * 90;
|
||||
|
||||
const getAbsoluteLocaleUrl = (locale: string, url: string) => `/${locale}${url}`;
|
||||
|
||||
const redirect = (redirectURL: string, headers: Record<string, string> = {}): Response => {
|
||||
return new Response(undefined, {
|
||||
headers: { ...headers, Location: redirectURL },
|
||||
status: 302,
|
||||
statusText: "Found",
|
||||
});
|
||||
};
|
||||
|
||||
const localeAgnosticPaths = ["/api/"];
|
||||
|
||||
const localeNegotiator = defineMiddleware(({ cookies, url, request }, next) => {
|
||||
if (localeAgnosticPaths.some((prefix) => url.pathname.startsWith(prefix))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const currentLocale = getCurrentLocale(url.pathname);
|
||||
const acceptedLocale = getBestAcceptedLanguage(request);
|
||||
const cookieLocale = getCookieLocale(cookies);
|
||||
const bestMatchingLocale = cookieLocale ?? acceptedLocale ?? currentLocale ?? defaultLocale;
|
||||
|
||||
if (!currentLocale) {
|
||||
const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, url.pathname);
|
||||
trackEvent("locale-redirect");
|
||||
return redirect(redirectURL);
|
||||
}
|
||||
|
||||
if (currentLocale !== bestMatchingLocale) {
|
||||
const pathnameWithoutLocale = url.pathname.substring(currentLocale.length + 1);
|
||||
const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, pathnameWithoutLocale);
|
||||
trackEvent("locale-redirect");
|
||||
return redirect(redirectURL);
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
const handleActionsSearchParams = defineMiddleware(
|
||||
async ({ url: { pathname, searchParams }, cookies }, next) => {
|
||||
const language = searchParams.get("action-lang");
|
||||
if (isValidLocale(language)) {
|
||||
const currentLocale = getCurrentLocale(pathname);
|
||||
const pathnameWithoutLocale = currentLocale
|
||||
? pathname.substring(currentLocale.length + 1)
|
||||
: pathname;
|
||||
const redirectURL = getAbsoluteLocaleUrl(language, pathnameWithoutLocale);
|
||||
trackEvent("action-lang");
|
||||
cookies.set(CookieKeys.Language, language, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
return redirect(redirectURL);
|
||||
}
|
||||
|
||||
const currency = searchParams.get("action-currency");
|
||||
if (isValidCurrency(currency)) {
|
||||
trackEvent("action-currency");
|
||||
cookies.set(CookieKeys.Currency, currency, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
return redirect(pathname);
|
||||
}
|
||||
|
||||
const theme = searchParams.get("action-theme");
|
||||
if (isValidTheme(theme)) {
|
||||
trackEvent("action-theme");
|
||||
cookies.set(CookieKeys.Theme, theme, {
|
||||
maxAge: theme === "auto" ? 0 : ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
return redirect(pathname);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
const refreshCookiesMaxAge = defineMiddleware(async ({ cookies }, next) => {
|
||||
const response = await next();
|
||||
|
||||
const theme = cookies.get(CookieKeys.Theme)?.value;
|
||||
if (isValidTheme(theme) && theme !== "auto") {
|
||||
cookies.set(CookieKeys.Theme, theme, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
} else if (theme) {
|
||||
cookies.set(CookieKeys.Theme, theme, {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
}
|
||||
|
||||
const currency = cookies.get(CookieKeys.Currency)?.value;
|
||||
if (isValidCurrency(currency)) {
|
||||
cookies.set(CookieKeys.Currency, currency, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
} else if (currency) {
|
||||
cookies.set(CookieKeys.Currency, currency, {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
}
|
||||
|
||||
const language = cookies.get(CookieKeys.Language)?.value;
|
||||
if (isValidLocale(language)) {
|
||||
cookies.set(CookieKeys.Language, language, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
} else if (language) {
|
||||
cookies.set(CookieKeys.Language, language, {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
const addContentLanguageResponseHeader = defineMiddleware(async ({ url }, next) => {
|
||||
const currentLocale = getCurrentLocale(url.pathname);
|
||||
|
||||
const response = await next();
|
||||
if (response.status === 200 && currentLocale) {
|
||||
response.headers.set("Content-Language", currentLocale);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
const addCacheControlHeaders = defineMiddleware(async (_, next) => {
|
||||
const response = await next();
|
||||
if (response.ok) {
|
||||
response.headers.set("Cache-Control", "max-age=60, stale-while-revalidate=60");
|
||||
response.headers.set("Vary", "Cookie")
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
const provideLocalsToRequest = defineMiddleware(async ({ url, locals, cookies }, next) => {
|
||||
locals.currentLocale = getCurrentLocale(url.pathname) ?? "en";
|
||||
locals.currentCurrency = getCookieCurrency(cookies) ?? "USD";
|
||||
locals.currentTheme = getCookieTheme(cookies) ?? "auto";
|
||||
return next();
|
||||
});
|
||||
|
||||
const analytics = defineMiddleware(async (context, next) => {
|
||||
const { request, params, locals, clientAddress } = context;
|
||||
const response = await next();
|
||||
trackRequest(request, { params, locals, clientAddress });
|
||||
return response;
|
||||
});
|
||||
import { sequence } from "astro:middleware";
|
||||
import { postProcessingMiddleware } from "src/middleware/postProcessing";
|
||||
import { localeNegotiationMiddleware } from "src/middleware/languageNegotiation";
|
||||
import { cookieRefreshingMiddleware } from "src/middleware/cookieRefreshing";
|
||||
import { addCommonHeadersMiddleware } from "src/middleware/commonHeaders";
|
||||
import { actionsHandlingMiddleware } from "src/middleware/actionsHandling";
|
||||
import { requestTrackingMiddleware } from "src/middleware/requestTracking";
|
||||
import { pageCachingMiddleware } from "src/middleware/pageCaching";
|
||||
import { setAstroLocalsMiddleware } from "src/middleware/setAstroLocals";
|
||||
import { removeTrailingSlashMiddleware } from "src/middleware/removeTrailingSlash";
|
||||
|
||||
export const onRequest = sequence(
|
||||
addContentLanguageResponseHeader,
|
||||
handleActionsSearchParams,
|
||||
refreshCookiesMaxAge,
|
||||
localeNegotiator,
|
||||
addCacheControlHeaders,
|
||||
provideLocalsToRequest,
|
||||
analytics
|
||||
// Possible redirect
|
||||
actionsHandlingMiddleware,
|
||||
localeNegotiationMiddleware,
|
||||
removeTrailingSlashMiddleware,
|
||||
|
||||
addCommonHeadersMiddleware,
|
||||
|
||||
// Get a response
|
||||
requestTrackingMiddleware,
|
||||
cookieRefreshingMiddleware,
|
||||
setAstroLocalsMiddleware,
|
||||
|
||||
// Generate body
|
||||
postProcessingMiddleware,
|
||||
pageCachingMiddleware
|
||||
);
|
||||
|
||||
/* LOCALE */
|
||||
|
||||
const getCurrentLocale = (pathname: string): string | undefined => {
|
||||
for (const locale of cache.locales) {
|
||||
if (pathname.split("/")[1] === locale.id) {
|
||||
return locale.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getBestAcceptedLanguage = (request: Request): string | undefined => {
|
||||
const header = request.headers.get("Accept-Language");
|
||||
if (!header) return;
|
||||
|
||||
acceptLanguage.languages(cache.locales.map(({ id }) => id));
|
||||
|
||||
return acceptLanguage.get(request.headers.get("Accept-Language")) ?? undefined;
|
||||
};
|
||||
|
||||
/* COOKIES */
|
||||
|
||||
export enum CookieKeys {
|
||||
Currency = "al_pref_currency",
|
||||
Theme = "al_pref_theme",
|
||||
Language = "al_pref_language",
|
||||
}
|
||||
|
||||
const themeSchema = z.enum(["dark", "light", "auto"]);
|
||||
|
||||
export const getCookieLocale = (cookies: AstroCookies): string | undefined => {
|
||||
const cookieValue = cookies.get(CookieKeys.Language)?.value;
|
||||
return isValidLocale(cookieValue) ? cookieValue : undefined;
|
||||
};
|
||||
|
||||
export const getCookieCurrency = (cookies: AstroCookies): string | undefined => {
|
||||
const cookieValue = cookies.get(CookieKeys.Currency)?.value;
|
||||
return isValidCurrency(cookieValue) ? cookieValue : undefined;
|
||||
};
|
||||
|
||||
export const getCookieTheme = (cookies: AstroCookies): z.infer<typeof themeSchema> | undefined => {
|
||||
const cookieValue = cookies.get(CookieKeys.Theme)?.value;
|
||||
return isValidTheme(cookieValue) ? cookieValue : undefined;
|
||||
};
|
||||
|
||||
export const isValidCurrency = (currency: string | null | undefined): currency is string =>
|
||||
currency !== null && currency != undefined && cache.currencies.includes(currency);
|
||||
|
||||
export const isValidLocale = (locale: string | null | undefined): locale is string =>
|
||||
locale !== null && locale != undefined && cache.locales.map(({ id }) => id).includes(locale);
|
||||
|
||||
export const isValidTheme = (
|
||||
theme: string | null | undefined
|
||||
): theme is z.infer<typeof themeSchema> => {
|
||||
const result = themeSchema.safeParse(theme);
|
||||
return result.success;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import {
|
||||
CookieKeys,
|
||||
getAbsoluteLocaleUrl,
|
||||
getCurrentLocale,
|
||||
isValidCurrency,
|
||||
isValidLocale,
|
||||
isValidTheme,
|
||||
redirect,
|
||||
} from "src/middleware/utils";
|
||||
import { analytics } from "src/services";
|
||||
|
||||
const ninetyDaysInSeconds = 60 * 60 * 24 * 90;
|
||||
|
||||
export const actionsHandlingMiddleware = defineMiddleware(async ({ url, cookies }, next) => {
|
||||
const language = url.searchParams.get("action-lang");
|
||||
if (isValidLocale(language)) {
|
||||
const currentLocale = getCurrentLocale(url.pathname);
|
||||
const pathnameWithoutLocale = currentLocale
|
||||
? url.pathname.substring(currentLocale.length + 1)
|
||||
: url.pathname;
|
||||
url.pathname = getAbsoluteLocaleUrl(language, pathnameWithoutLocale);
|
||||
url.searchParams.delete("action-lang");
|
||||
analytics.trackEvent("action-lang");
|
||||
cookies.set(CookieKeys.Language, language, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
return redirect(url.toString());
|
||||
}
|
||||
|
||||
const currency = url.searchParams.get("action-currency");
|
||||
if (isValidCurrency(currency)) {
|
||||
analytics.trackEvent("action-currency");
|
||||
cookies.set(CookieKeys.Currency, currency, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
url.searchParams.delete("action-currency");
|
||||
return redirect(url.toString());
|
||||
}
|
||||
|
||||
const theme = url.searchParams.get("action-theme");
|
||||
if (isValidTheme(theme)) {
|
||||
analytics.trackEvent("action-theme");
|
||||
cookies.set(CookieKeys.Theme, theme, {
|
||||
maxAge: theme === "auto" ? 0 : ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
url.searchParams.delete("action-theme");
|
||||
return redirect(url.toString());
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
|
@ -0,0 +1,25 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { getCurrentLocale } from "src/middleware/utils";
|
||||
|
||||
export const addCommonHeadersMiddleware = defineMiddleware(async ({ url }, next) => {
|
||||
const response = await next();
|
||||
|
||||
const currentLocale = getCurrentLocale(url.pathname);
|
||||
if (currentLocale) {
|
||||
response.headers.set("Content-Language", currentLocale);
|
||||
}
|
||||
|
||||
// TODO: Remove when in production
|
||||
response.headers.set("X-Robots-Tag", "none");
|
||||
response.headers.set("Vary", "Cookie");
|
||||
|
||||
if (!response.headers.has("cache-control")) {
|
||||
if (import.meta.env.CACHE_CONTROL !== "true" && !response.headers.has("cache-control")) {
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
} else {
|
||||
response.headers.set("Cache-Control", "max-age=86400, stale-while-revalidate=86400");
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { CookieKeys, isValidCurrency, isValidLocale, isValidTheme } from "src/middleware/utils";
|
||||
|
||||
const ninetyDaysInSeconds = 60 * 60 * 24 * 90;
|
||||
|
||||
export const cookieRefreshingMiddleware = defineMiddleware(async ({ cookies }, next) => {
|
||||
const response = await next();
|
||||
|
||||
const theme = cookies.get(CookieKeys.Theme)?.value;
|
||||
if (isValidTheme(theme) && theme !== "auto") {
|
||||
cookies.set(CookieKeys.Theme, theme, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
} else if (theme) {
|
||||
cookies.set(CookieKeys.Theme, theme, {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
}
|
||||
|
||||
const currency = cookies.get(CookieKeys.Currency)?.value;
|
||||
if (isValidCurrency(currency)) {
|
||||
cookies.set(CookieKeys.Currency, currency, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
} else if (currency) {
|
||||
cookies.set(CookieKeys.Currency, currency, {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
}
|
||||
|
||||
const language = cookies.get(CookieKeys.Language)?.value;
|
||||
if (isValidLocale(language)) {
|
||||
cookies.set(CookieKeys.Language, language, {
|
||||
maxAge: ninetyDaysInSeconds,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
} else if (language) {
|
||||
cookies.set(CookieKeys.Language, language, {
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { defaultLocale } from "src/i18n/i18n";
|
||||
import {
|
||||
getAbsoluteLocaleUrl,
|
||||
getBestAcceptedLanguage,
|
||||
getCookieLocale,
|
||||
getCurrentLocale,
|
||||
redirect,
|
||||
} from "src/middleware/utils";
|
||||
import { analytics } from "src/services";
|
||||
|
||||
const localeAgnosticPaths = ["/api/"];
|
||||
|
||||
export const localeNegotiationMiddleware = defineMiddleware(({ cookies, url, request }, next) => {
|
||||
if (localeAgnosticPaths.some((prefix) => url.pathname.includes(prefix))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const currentLocale = getCurrentLocale(url.pathname);
|
||||
const acceptedLocale = getBestAcceptedLanguage(request);
|
||||
const cookieLocale = getCookieLocale(cookies);
|
||||
const bestMatchingLocale = cookieLocale ?? acceptedLocale ?? currentLocale ?? defaultLocale;
|
||||
|
||||
if (!currentLocale) {
|
||||
const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, url.pathname);
|
||||
analytics.trackEvent("locale-redirect");
|
||||
return redirect(redirectURL);
|
||||
}
|
||||
|
||||
if (currentLocale !== bestMatchingLocale) {
|
||||
const pathnameWithoutLocale = url.pathname.substring(currentLocale.length + 1);
|
||||
const redirectURL = getAbsoluteLocaleUrl(bestMatchingLocale, pathnameWithoutLocale);
|
||||
analytics.trackEvent("locale-redirect");
|
||||
return redirect(redirectURL);
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { pageCache } from "src/services";
|
||||
|
||||
export const pageCachingMiddleware = defineMiddleware(async ({ url, request, locals }, next) => {
|
||||
const pathname = url.pathname;
|
||||
const cachedPage = pageCache.get(pathname);
|
||||
|
||||
if (cachedPage) {
|
||||
const clientTimestamp = request.headers.get("If-Modified-Since");
|
||||
const serverTimestamp = cachedPage.headers.get("Last-Modified");
|
||||
|
||||
if (
|
||||
clientTimestamp &&
|
||||
serverTimestamp &&
|
||||
new Date(clientTimestamp) == new Date(serverTimestamp)
|
||||
) {
|
||||
return new Response(null, { status: 304, statusText: "Not Modified" });
|
||||
}
|
||||
|
||||
return cachedPage;
|
||||
}
|
||||
|
||||
const response = await next();
|
||||
|
||||
if (response.ok) {
|
||||
response.headers.set("Last-Modified", new Date().toUTCString());
|
||||
|
||||
if (locals.pageCaching) {
|
||||
pageCache.set(pathname, response);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
|
@ -0,0 +1,99 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { getI18n } from "src/i18n/i18n";
|
||||
import { getCookieCurrency, getCookieTheme } from "src/middleware/utils";
|
||||
import { convert } from "src/utils/currencies";
|
||||
|
||||
export enum PostProcessingTags {
|
||||
HTML_CLASS = "POST_PROCESS_HTML_CLASS",
|
||||
PRICE_START = "POST_PROCESS_PRICE_START",
|
||||
PRICE_END = "POST_PROCESS_PRICE_END",
|
||||
PREFERRED_CURRENCY = "POST_PROCESS_PREFERRED_CURRENCY",
|
||||
CURRENCY_UNDERLINE_START = "POST_PROCESS_CURRENCY_UNDERLINE_START",
|
||||
CURRENCY_UNDERLINE_END = "POST_PROCESS_CURRENCY_UNDERLINE_END",
|
||||
}
|
||||
|
||||
const priceRegex = new RegExp(
|
||||
`${PostProcessingTags.PRICE_START}(.*?)${PostProcessingTags.PRICE_END}`,
|
||||
"g"
|
||||
);
|
||||
|
||||
const selectedCurrencyRegex = new RegExp(
|
||||
`${PostProcessingTags.CURRENCY_UNDERLINE_START}(.*?)${PostProcessingTags.CURRENCY_UNDERLINE_END}`,
|
||||
"g"
|
||||
);
|
||||
|
||||
type PostProcessingCurrency = {
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export const prepareClassForSelectedCurrencyPostProcessing = (currency: PostProcessingCurrency) =>
|
||||
`${PostProcessingTags.CURRENCY_UNDERLINE_START}${JSON.stringify(currency)}${PostProcessingTags.CURRENCY_UNDERLINE_END}`;
|
||||
|
||||
type PostProcessingPrice = {
|
||||
amount: number;
|
||||
currency: string;
|
||||
format: "short" | "long";
|
||||
};
|
||||
|
||||
export const formatPriceForPostProcessing = (
|
||||
price: Omit<PostProcessingPrice, "format">,
|
||||
format: "short" | "long"
|
||||
): string =>
|
||||
`${PostProcessingTags.PRICE_START}${JSON.stringify({ ...price, format })}${PostProcessingTags.PRICE_END}`;
|
||||
|
||||
export const postProcessingMiddleware = defineMiddleware(async ({ cookies, locals }, next) => {
|
||||
const { formatPrice, t } = await getI18n(locals.currentLocale);
|
||||
const currentCurrency = getCookieCurrency(cookies) ?? "USD";
|
||||
|
||||
const response = await next();
|
||||
let html = await response.text();
|
||||
|
||||
// HTML CLASS
|
||||
const currentTheme = getCookieTheme(cookies) ?? "auto";
|
||||
html = html.replace(
|
||||
PostProcessingTags.HTML_CLASS,
|
||||
currentTheme === "dark" ? "dark-theme" : currentTheme === "light" ? "light-theme" : ""
|
||||
);
|
||||
|
||||
// PRICES
|
||||
html = html.replaceAll(priceRegex, (_, priceString) => {
|
||||
const unescapedString = priceString.replaceAll(""", '"');
|
||||
const price = JSON.parse(unescapedString) as PostProcessingPrice;
|
||||
|
||||
if (price.amount === 0) {
|
||||
return t("collectibles.price.free");
|
||||
}
|
||||
|
||||
if (currentCurrency === price.currency) {
|
||||
return formatPrice(price);
|
||||
}
|
||||
|
||||
const convertedPrice = {
|
||||
amount: convert(price.currency, currentCurrency, price.amount),
|
||||
currency: currentCurrency,
|
||||
};
|
||||
|
||||
if (price.format === "long") {
|
||||
return `${formatPrice(price)} (${formatPrice(convertedPrice)})`;
|
||||
} else {
|
||||
return formatPrice(convertedPrice);
|
||||
}
|
||||
});
|
||||
|
||||
// PREFERRED_CURRENCY
|
||||
html = html.replace(PostProcessingTags.PREFERRED_CURRENCY, currentCurrency.toUpperCase());
|
||||
|
||||
// SELECTED CURRENCY CLASS
|
||||
html = html.replaceAll(selectedCurrencyRegex, (_, selectedCurrency) => {
|
||||
const unescapedString = selectedCurrency.replaceAll(""", '"');
|
||||
const currency = JSON.parse(unescapedString) as PostProcessingCurrency;
|
||||
|
||||
if (currentCurrency === currency.currency) {
|
||||
return "current";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(html, response);
|
||||
});
|
|
@ -0,0 +1,10 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { redirect } from "src/middleware/utils";
|
||||
|
||||
export const removeTrailingSlashMiddleware = defineMiddleware(({ url }, next) => {
|
||||
if (url.pathname.endsWith("/")) {
|
||||
url.pathname = url.pathname.substring(0, url.pathname.length - 1);
|
||||
return redirect(url.toString());
|
||||
}
|
||||
return next();
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
import { defineMiddleware } from "astro/middleware";
|
||||
import { analytics } from "src/services";
|
||||
|
||||
export const requestTrackingMiddleware = defineMiddleware(async (context, next) => {
|
||||
const { request, locals, clientAddress } = context;
|
||||
const response = await next();
|
||||
analytics.trackRequest(request, {
|
||||
clientAddress,
|
||||
locale: locals.currentLocale,
|
||||
responseStatus: response.status,
|
||||
});
|
||||
return response;
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { getCurrentLocale } from "src/middleware/utils";
|
||||
|
||||
export const setAstroLocalsMiddleware = defineMiddleware(async ({ url, locals }, next) => {
|
||||
locals.currentLocale = getCurrentLocale(url.pathname) ?? "en";
|
||||
locals.pageCaching = true;
|
||||
return next();
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
import type { AstroCookies } from "astro";
|
||||
import { z } from "astro:content";
|
||||
import { contextCache } from "src/services";
|
||||
import acceptLanguage from "accept-language";
|
||||
|
||||
export const getAbsoluteLocaleUrl = (locale: string, url: string) => `/${locale}${url}`;
|
||||
|
||||
export const redirect = (redirectURL: string, headers: Record<string, string> = {}): Response => {
|
||||
return new Response(undefined, {
|
||||
headers: { ...headers, Location: redirectURL },
|
||||
status: 302,
|
||||
statusText: "Found",
|
||||
});
|
||||
};
|
||||
|
||||
/* LOCALE */
|
||||
|
||||
export const getCurrentLocale = (pathname: string): string | undefined => {
|
||||
for (const locale of contextCache.locales) {
|
||||
if (pathname.split("/")[1] === locale) {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getBestAcceptedLanguage = (request: Request): string | undefined => {
|
||||
const header = request.headers.get("Accept-Language");
|
||||
if (!header || header === "*") return;
|
||||
|
||||
acceptLanguage.languages(contextCache.locales);
|
||||
|
||||
return acceptLanguage.get(request.headers.get("Accept-Language")) ?? undefined;
|
||||
};
|
||||
|
||||
/* COOKIES */
|
||||
|
||||
export enum CookieKeys {
|
||||
Currency = "al_pref_currency",
|
||||
Theme = "al_pref_theme",
|
||||
Language = "al_pref_language",
|
||||
}
|
||||
|
||||
const themeSchema = z.enum(["dark", "light", "auto"]);
|
||||
|
||||
export const getCookieLocale = (cookies: AstroCookies): string | undefined => {
|
||||
const cookieValue = cookies.get(CookieKeys.Language)?.value;
|
||||
return isValidLocale(cookieValue) ? cookieValue : undefined;
|
||||
};
|
||||
|
||||
export const getCookieCurrency = (cookies: AstroCookies): string | undefined => {
|
||||
const cookieValue = cookies.get(CookieKeys.Currency)?.value;
|
||||
return isValidCurrency(cookieValue) ? cookieValue : undefined;
|
||||
};
|
||||
|
||||
export const getCookieTheme = (cookies: AstroCookies): z.infer<typeof themeSchema> | undefined => {
|
||||
const cookieValue = cookies.get(CookieKeys.Theme)?.value;
|
||||
return isValidTheme(cookieValue) ? cookieValue : undefined;
|
||||
};
|
||||
|
||||
export const isValidCurrency = (currency: string | null | undefined): currency is string =>
|
||||
currency !== null && currency != undefined && contextCache.currencies.includes(currency);
|
||||
|
||||
export const isValidLocale = (locale: string | null | undefined): locale is string =>
|
||||
locale !== null && locale != undefined && contextCache.locales.includes(locale);
|
||||
|
||||
export const isValidTheme = (
|
||||
theme: string | null | undefined
|
||||
): theme is z.infer<typeof themeSchema> => {
|
||||
const result = themeSchema.safeParse(theme);
|
||||
return result.success;
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue