Compare commits

..

1 Commits

Author SHA1 Message Date
DrMint 67de6b0b6a Added basic row view 2024-06-15 09:43:11 +02:00
168 changed files with 5974 additions and 4946 deletions

View File

@ -8,19 +8,8 @@ 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

3
.gitignore vendored
View File

@ -19,6 +19,3 @@ pnpm-debug.log*
# macOS-specific files
.DS_Store
# caching
.cache

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "src/shared"]
path = src/shared
url = https://github.com/Accords-Library/shared-library.git

View File

@ -1,2 +1,2 @@
src/shared/*
dist
!src/shared/analytics

View File

@ -1,4 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode", "antfu.iconify", "esbenp.prettier-vscode"],
"recommendations": ["astro-build.astro-vscode", "antfu.iconify"],
"unwantedRecommendations": []
}

View File

@ -1,11 +1,5 @@
{
"editor.rulers": [100],
"editor.tabSize": 2,
"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"
}
"typescript.preferences.importModuleSpecifier": "non-relative"
}

View File

@ -1,17 +1,17 @@
## DOD
- Jugment chapter 1
- Jugment
- Scans
- Gallery (add the magazine cover)
- Magnitude negative
- Tags for Chapter 2 and up
- Soundtrack
- Missing tags
- Soundtrack vol1
- Missing scans
- booklet?
- Soundtrack vol2
- Missing cd
- Missing scans
- booklet?
- Missing scans booklet / images
- Missing vol2 cd
- 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
- missing parent collectible
- 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
@ -19,19 +19,33 @@
- https://v3.accords-library.com/en/folders/drakengard-promotional-videos
- https://v3.accords-library.com/en/videos/663354cb12807e2ec1b33924
- https://v3.accords-library.com/en/collectibles/dod-scenario-script
- https://v3.accords-library.com/en/collectibles/dod-window-script
- https://v3.accords-library.com/en/pages/dod1-questions-and-answers
- https://v3.accords-library.com/en/pages/looking-back-1
- https://v3.accords-library.com/en/pages/song-of-fourteen-years
- Missing collectibles
- https://accords-library.com/library/dod-the-archive-of
- https://accords-library.com/library/dod-reservation-promo-dvd
- https://accords-library.com/library/dod-promo-dvd
- Games
- https://accords-library.com/library/dod-java
- https://accords-library.com/library/dod-demo
- Missing pages
- https://accords-library.com/contents/super-space-time-character-exchange-roundtable-in-shinjuku
- https://accords-library.com/contents/dod-history
## DOD1.3
- Drag-on Dragoon - Fatal Crimson
- Drag-on Dragoon - Shi ni Itaru Aka
- Basic Knowledge of Drakengard
- Missing parent collectible
- https://v3.accords-library.com/en/folders/drakengard-1-3
- Missing footnotes
- https://v3.accords-library.com/en/pages/dod1-3-a-2040-battle-with-the-empire
@ -41,19 +55,23 @@
## DOD 2
- Missing pages
- https://accords-library.com/contents/folder/dod2-promotional-materials
- https://accords-library.com/contents/super-space-time-character-exchange-roundtable-in-shinjuku
- https://accords-library.com/contents/dod-history
- Missing collectibles
- Games
- https://accords-library.com/library/dod2-demo
- 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
- https://accords-library.com/library/dod2-original-soundtrack
- https://accords-library.com/library/dod2-scenario-script-a
- https://accords-library.com/library/dod2-scenario-script-b
- https://accords-library.com/library/dod2-story-side
- https://accords-library.com/library/dod2-the-archive-of
- https://accords-library.com/library/dod2-web-material
- https://accords-library.com/library/dod2-strategy-guidebook
- Missing footnotes
@ -65,7 +83,6 @@
## DOD3
- Missing collectibles
- https://accords-library.com/library/utahime-five
- https://accords-library.com/library/dod3-official-score-book
- Soundtrack
@ -73,7 +90,6 @@
- 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
@ -82,30 +98,19 @@
- https://accords-library.com/contents/prophecy
- https://accords-library.com/contents/dod3-complete-guide-epilogue
- https://accords-library.com/contents/super-space-time-character-exchange-roundtable-in-shinjuku
- https://accords-library.com/contents/dod-history
- Missing parent page
- Novellas
- https://accords-library.com/contents/folder/dod3-dlc-short-stories
- https://accords-library.com/contents/folder/dod3-prelude-novellas
- https://accords-library.com/contents/folder/dod3-dlc-short-stories
- Story side
- https://accords-library.com/contents/folder/dod3-story-side
- 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 cant fight blood.”, a likely prod at Furiaes 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
- Check release data for https://v3.accords-library.com/en/collectibles/dod10th-anniversary-box and its subitems

View File

@ -1,7 +1,5 @@
# Accord's Library
Accord's Library v3.0 (shorten to AL3.0)
## Tech overview
- Client-side framework: None
@ -9,20 +7,18 @@ Accord's Library v3.0 (shorten to AL3.0)
- Content management system: [Payload](https://payloadcms.com)
- Database: MongoDB
## Core principles
## 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).
- Embrace web standards instead of reinventing the wheel
- [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.
- [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.
- 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
@ -72,28 +68,18 @@ Read more:
- Lazy loaded
- Space reservation to reduce Cumulative Layout Shift
- Use of efficient formats (mostly WebP) and meaningful quality settings
- Server side rendered
- Server side rendered (both good and bad for speed)
- Reduced data transfer
- Reduced client-side complexity
- 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.
- 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)
- 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 indexed seperately.
- Each language variants are indexes seperately.
- Complexity
- The complexity should be moved away from public-facing parts of the codebase
@ -106,6 +92,7 @@ Read more:
- 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
@ -118,7 +105,6 @@ Read more:
- 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..."
@ -142,6 +128,13 @@ 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.

22
TODO.md
View File

@ -2,23 +2,18 @@
## 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] [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)
- [Bugs] [Language override] Maso actor is not focusable with keyboard nav
## Short term
- [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] 404, 500 pages
- [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
@ -26,7 +21,7 @@
- [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
- ISR https://logsnag.com/blog/implementing-isr-in-astro
- [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?
@ -40,8 +35,9 @@
## 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] Speed up loading with https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
- [Feat] More data caching between the CMS and Astro
- Etag? Last-Modified? Cache-control
- [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
@ -76,4 +72,4 @@
- [Feat] Static HTML site export for archival
- [Feat] Secret Terminal mode
- [Feat] Add RSS
- [Feat] Add RSS

View File

@ -11,22 +11,10 @@ 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: {
@ -35,7 +23,7 @@ export default defineConfig({
}),
],
prefetch: false,
devToolbar: { enabled: false },
// devToolbar: { enabled: false },
server: {
port: parseInt(ASTRO_PORT ?? "4321"),
host: ASTRO_HOST ?? true,

3138
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "v3.accords-library.com",
"version": "3.0.0-beta.9",
"version": "3.0.0-beta.3",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
@ -8,33 +8,34 @@
"preview": "astro preview",
"astro": "astro",
"upgrade": "ncu",
"fetch-submodules": "cd src/shared && git pull && cd ../..",
"script:download-currencies": "node --env-file=.env scripts/download-currencies.mjs",
"script:download-payload-sdk": "npm run scripts/download-payload-sdk.ts",
"script:download-currencies": "npm run scripts/download-currencies.ts",
"script:download-wording-keys": "npm run scripts/download-wording-keys.ts",
"prettier": "prettier --write --list-different --plugin=prettier-plugin-astro .",
"precommit": "npm run fetch-submodules && npm run script:download-currencies && npm run script:download-wording-keys && npm run prettier && npm run astro check"
"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"
},
"engines": {
"npm": ">=10.0.0",
"node": ">=19.7.0"
},
"dependencies": {
"@astrojs/check": "^0.9.2",
"@astrojs/node": "^8.3.3",
"accept-language": "^3.0.20",
"astro": "4.14.0",
"@astrojs/check": "^0.7.0",
"@astrojs/node": "^8.3.0",
"accept-language": "^3.0.18",
"astro": "4.10.2",
"astro-icon": "^1.1.0",
"node-cache": "^5.1.2",
"tippy.js": "^6.3.7",
"ua-parser-js": "^1.0.38"
},
"devDependencies": {
"@iconify-json/material-symbols": "^1.1.87",
"@iconify-json/material-symbols": "^1.1.82",
"@types/ua-parser-js": "^0.7.39",
"astro-meta-tags": "^0.3.0",
"autoprefixer": "^10.4.20",
"postcss-preset-env": "^10.0.1",
"prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.1",
"typescript": "^5.5.4"
"autoprefixer": "^10.4.19",
"postcss-preset-env": "^9.5.14",
"prettier": "^3.3.2",
"prettier-plugin-astro": "^0.14.0",
"typescript": "^5.4.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,4 +1,2 @@
User-agent: *
Disallow: /
Disallow: /*/api/
Disallow: /*/dev/
Disallow: /

View File

@ -1,4 +1,3 @@
rm -r dist
npm ci
npm run precommit
npm run build

View File

@ -1,7 +1,6 @@
// @ts-check
import { writeFileSync, readFileSync, existsSync, mkdirSync } from "fs";
import { writeFileSync, readFileSync, existsSync } from "fs";
const OPEN_EXCHANGE_FOLDER = `${process.cwd()}/src/dist/openExchange`;
const OPEN_EXCHANGE_FOLDER = `${process.cwd()}/src/shared/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;
@ -18,21 +17,15 @@ if (existsSync(RATE_JSON_PATH)) {
}
}
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 ratesUrl = `https://openexchangerates.org/api/latest.json?app_id=${
import.meta.env.OER_APP_ID
}`;
const currenciesUrl = `https://openexchangerates.org/api/currencies.json?app_id=${
process.env.OER_APP_ID
import.meta.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",

View File

@ -0,0 +1,14 @@
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",
});
}

View File

@ -1,16 +1,10 @@
import { writeFileSync } from "fs";
import { PayloadSDK } from "src/shared/payload/sdk";
import { payload } from "src/utils/payload";
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 { data: wordings } = await payload.getWordings();
const wordings = await payload.getWordings();
const keys = wordings.map(({ name }) => name);
let result = "";

View File

@ -1,77 +0,0 @@
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");
}
}

125
src/cache/dataCache.ts vendored
View File

@ -1,125 +0,0 @@
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);
}
}

235
src/cache/pageCache.ts vendored
View File

@ -1,235 +0,0 @@
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());
}
}

View File

@ -1,20 +0,0 @@
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.");
}
}

View File

@ -2,22 +2,25 @@
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,
topBar = {},
hideHomeButton = false,
...otherProps
} = Astro.props;
---
@ -27,7 +30,7 @@ const {
<Html openGraph={openGraph}>
{backgroundImage && <AppLayoutBackgroundImg img={backgroundImage} />}
<header>
<Topbar {...topBar} />
<Topbar parentPages={parentPages} hideHomeButton={hideHomeButton} />
</header>
<main {...otherProps.class ? otherProps : {}}><slot /></main>
<Footer withLinks={!hideFooterLinks} />

View File

@ -30,32 +30,20 @@ const { reducedAsideWidth = false } = Astro.props;
<div id="layout" class:list={{ "reduced-width": reducedAsideWidth }}>
<div id="left">
<slot name="header" />
{
(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>
)
}
<div class="when-not-large">
<slot name="header-aside" />
<slot name="meta" />
<slot name="aside" />
</div>
<div class="when-large">
<slot name="meta" />
</div>
<slot />
</div>
{
(Astro.slots.has("header-aside") || Astro.slots.has("aside")) && (
<Card class="when-large right">
<slot name="header-aside" />
<slot name="aside" />
</Card>
)
}
<Card class="when-large right">
<slot name="header-aside" />
<slot name="aside" />
</Card>
</div>
{/* ------------------------------------------- CSS -------------------------------------------- */}

View File

@ -1,14 +1,14 @@
---
import { getRandomId } from "src/utils/random";
import { sizesToSrcset } from "src/utils/img";
import type {
EndpointImage,
EndpointPayloadImage,
EndpointMediaThumbnail,
EndpointScanImage,
} from "src/shared/payload/endpoint-types";
} from "src/shared/payload/payload-sdk";
import { getRandomId } from "src/utils/random";
import { sizesToSrcset } from "src/utils/img";
interface Props {
img: EndpointImage | EndpointPayloadImage | EndpointScanImage;
img: EndpointImage | EndpointMediaThumbnail | EndpointScanImage;
}
const {
@ -100,7 +100,6 @@ 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(

View File

@ -7,7 +7,7 @@ interface Props {
}
const { withLinks } = Astro.props;
const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
const { t } = await getI18n(Astro.locals.currentLocale);
const discordLabel = `${t("footer.socials.discord.title")} - ${t(
"footer.socials.discord.subtitle"
@ -30,11 +30,7 @@ const contactLabel = `${t("footer.socials.contact.title")} - ${t(
{
withLinks && (
<div id="socials" class="when-no-print">
<a
href={getLocalizedUrl("/discord")}
class="pressable-label"
aria-label={discordLabel}
title={discordLabel}>
<a href="/discord" class="pressable-label" aria-label={discordLabel} title={discordLabel}>
<Icon name="discord-brands" />
<p class="font-s">{t("footer.socials.discord.title")}</p>
</a>
@ -105,7 +101,6 @@ 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) {

View File

@ -1,21 +1,20 @@
---
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,
EndpointPayloadImage,
EndpointMediaThumbnail,
EndpointVideo,
} from "src/shared/payload/endpoint-types";
} from "src/shared/payload/payload-sdk";
import { cache } from "src/utils/payload";
interface Props {
openGraph?:
| {
title?: string | undefined;
description?: string | undefined;
thumbnail?: EndpointImage | EndpointPayloadImage | undefined;
thumbnail?: EndpointImage | EndpointMediaThumbnail | undefined;
audio?: EndpointAudio | undefined;
video?: EndpointVideo | undefined;
}
@ -29,9 +28,7 @@ const { openGraph = {} } = Astro.props;
const { description = t("global.meta.description"), audio, video } = openGraph;
const thumbnail =
openGraph.thumbnail?.openGraph ??
openGraph.thumbnail ??
contextCache.config.defaultOpenGraphImage;
openGraph.thumbnail?.openGraph ?? openGraph.thumbnail ?? cache.config.defaultOpenGraphImage;
const title = openGraph.title
? `${openGraph.title} ${t("global.siteName")}`
@ -41,6 +38,8 @@ 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 -------------------------------------------- */
---
@ -48,7 +47,9 @@ const isIOS = parser.getOS().name === "iOS";
<html
lang={currentLocale}
class:list={{
[PostProcessingTags.HTML_CLASS]: true,
"manual-theme": currentTheme !== "auto",
"light-theme": currentTheme === "light",
"dark-theme": currentTheme === "dark",
"texture-dots": !isIOS,
"font-m": true,
"debug-lang": false,
@ -58,6 +59,8 @@ const isIOS = parser.getOS().name === "iOS";
<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
@ -72,8 +75,6 @@ const isIOS = parser.getOS().name === "iOS";
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" />
@ -130,6 +131,16 @@ const isIOS = parser.getOS().name === "iOS";
</>
)
}
<noscript>
<style is:global>
.when-js {
display: none !important;
visibility: none !important;
opacity: 0 !important;
}
</style>
</noscript>
</head>
<body>
@ -299,7 +310,7 @@ const isIOS = parser.getOS().name === "iOS";
color: var(--color-base-1000);
}
html:not(.light-theme, .dark-theme) {
html:not(.manual-theme) {
@media (prefers-color-scheme: light) {
& .when-dark-theme {
display: none !important;
@ -674,14 +685,10 @@ const isIOS = parser.getOS().name === "iOS";
display: flex;
place-items: center;
gap: 0.4em;
padding: 0.7em 1.1em;
padding: 0.7em 0.8em;
border-radius: 9999px;
cursor: pointer;
&:has(svg) {
padding-left: 0.8em;
}
transition: 150ms background-color;
&:hover,
@ -809,26 +816,14 @@ const isIOS = parser.getOS().name === "iOS";
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");

View File

@ -4,23 +4,16 @@ 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 RelationsButton from "./components/RelationsButton.astro";
import ParentPagesButton from "./components/ParentPagesButton.astro";
import { getI18n } from "src/i18n/i18n";
import type { EndpointRelation } from "src/shared/payload/endpoint-types";
import type { EndpointSource } from "src/shared/payload/payload-sdk";
interface Props {
relations?: EndpointRelation[] | undefined;
relationPageUrl?: string | undefined;
hideHomeButton?: boolean | undefined;
hideSearchButton?: boolean | undefined;
parentPages?: EndpointSource[] | undefined;
hideHomeButton?: boolean;
}
const {
relations = [],
relationPageUrl,
hideHomeButton = false,
hideSearchButton = false,
} = Astro.props;
const { parentPages = [], hideHomeButton = false } = Astro.props;
const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
---
@ -29,31 +22,23 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
<nav id="topbar" class="when-no-print">
{
(!hideHomeButton || relations.length > 0) && (
(!hideHomeButton || parentPages.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>
{relations.length > 0 && (
<RelationsButton relations={relations} relationPageUrl={relationPageUrl} />
)}
{parentPages.length > 0 && <ParentPagesButton parentPages={parentPages} />}
</div>
)
}
<div id="toolbar" class="hide-scrollbar">
{
!hideSearchButton && (
<>
<a href={getLocalizedUrl("/search")} title={t("header.topbar.search.tooltip")}>
<Button icon="material-symbols:search" />
</a>
<div class="separator" />
</>
)
}
<a href={getLocalizedUrl("/search")} aria-label={t("header.topbar.search.tooltip")} hidden>
<Button icon="material-symbols:search" />
</a>
<div class="separator" hidden></div>
<div class="when-no-js">
<a href="/settings">

View File

@ -2,12 +2,8 @@
import Button from "components/Button.astro";
import Tooltip from "components/Tooltip.astro";
import { getI18n } from "src/i18n/i18n";
import { contextCache } from "src/services";
import { cache } from "src/utils/payload";
import { formatCurrency } from "src/utils/currencies";
import {
PostProcessingTags,
prepareClassForSelectedCurrencyPostProcessing,
} from "src/middleware/postProcessing";
interface Props {
withTitle?: boolean | undefined;
@ -17,12 +13,7 @@ interface Props {
const { withTitle, ...otherProps } = Astro.props;
const { t } = await getI18n(Astro.locals.currentLocale);
const getActionCurrency = (value: string) => {
const requestSearchParams = new URL(Astro.request.url).searchParams;
const newSearchParams = new URLSearchParams(requestSearchParams);
newSearchParams.set("action-currency", value);
return `?${newSearchParams}`;
};
const { currentCurrency } = Astro.locals;
---
{/* ------------------------------------------- HTML ------------------------------------------- */}
@ -30,13 +21,14 @@ const getActionCurrency = (value: string) => {
<Tooltip trigger="click" {...otherProps.class ? otherProps : {}}>
<div id="content" slot="tooltip-content">
{
contextCache.currencies.map((id) => (
cache.currencies.map((id) => (
<a
class:list={[
"pressable-link",
prepareClassForSelectedCurrencyPostProcessing({ currency: id }),
]}
href={getActionCurrency(id)}>
class:list={{
current: currentCurrency === id,
"pressable-link": true,
}}
href={`?action-currency=${id}`}
data-astro-prefetch="tap">
{`${id} (${formatCurrency(id)})`}
</a>
))
@ -44,7 +36,7 @@ const getActionCurrency = (value: string) => {
</div>
<Button
icon="material-symbols:currency-exchange"
title={withTitle ? PostProcessingTags.PREFERRED_CURRENCY : undefined}
title={withTitle ? currentCurrency.toUpperCase() : undefined}
ariaLabel={t("header.topbar.currency.tooltip")}
/>
</Tooltip>

View File

@ -2,7 +2,7 @@
import Button from "components/Button.astro";
import Tooltip from "components/Tooltip.astro";
import { getI18n } from "src/i18n/i18n";
import { contextCache } from "src/services";
import { cache } from "src/utils/payload";
import { formatLocale } from "src/utils/format";
interface Props {
@ -14,13 +14,6 @@ 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 ------------------------------------------- */}
@ -28,11 +21,12 @@ const getActionLanguage = (value: string) => {
<Tooltip trigger="click" {...otherProps.class ? otherProps : {}}>
<div id="content" slot="tooltip-content">
{
contextCache.locales.map((locale) => (
cache.locales.map(({ id }) => (
<a
class:list={{ current: currentLocale === locale, "pressable-link": true }}
href={getActionLanguage(locale)}>
{formatLocale(locale)}
class:list={{ current: currentLocale === id, "pressable-link": true }}
href={`?action-lang=${id}`}
data-astro-prefetch="tap">
{formatLocale(id)}
</a>
))
}

View File

@ -0,0 +1,59 @@
---
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>

View File

@ -1,87 +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 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>

View File

@ -1,14 +1,14 @@
---
import { Icon } from "astro-icon/components";
import { getI18n } from "src/i18n/i18n";
import type { EndpointRelation } from "src/shared/payload/endpoint-types";
import type { EndpointSource } from "src/shared/payload/payload-sdk";
interface Props {
relation: EndpointRelation;
parentPage: EndpointSource;
}
const { relation } = Astro.props;
const { formatEndpointRelation } = await getI18n(Astro.locals.currentLocale);
const { parentPage } = Astro.props;
const { formatEndpointSource } = await getI18n(Astro.locals.currentLocale);
const {
href,
@ -17,7 +17,7 @@ const {
target = undefined,
rel = undefined,
lang,
} = formatEndpointRelation(relation);
} = formatEndpointSource(parentPage);
---
{/* ------------------------------------------- HTML ------------------------------------------- */}

View File

@ -1,37 +0,0 @@
---
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>

View File

@ -3,28 +3,27 @@ import Button from "components/Button.astro";
import Tooltip from "components/Tooltip.astro";
import { getI18n } from "src/i18n/i18n";
const { currentLocale } = Astro.locals;
const { currentLocale, currentTheme } = 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="pressable-link underline-when-dark" href={getActionThemeUrl("dark")}>
<a
class:list={{ current: currentTheme === "dark", "pressable-link": true }}
href="?action-theme=dark">
{t("global.theme.dark")}
</a>
<a class="pressable-link underline-when-auto" href={getActionThemeUrl("auto")}>
<a
class:list={{ current: currentTheme === "auto", "pressable-link": true }}
href="?action-theme=auto">
{t("global.theme.auto")}
</a>
<a class="pressable-link underline-when-light" href={getActionThemeUrl("light")}>
<a
class:list={{ current: currentTheme === "light", "pressable-link": true }}
href="?action-theme=light">
{t("global.theme.light")}
</a>
</div>
@ -46,12 +45,10 @@ const getActionThemeUrl = (value: "dark" | "light" | "auto") => {
#content {
display: grid;
gap: 0.5em;
}
: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);
& > .current {
color: var(--color-base-750);
text-decoration: underline 0.08em var(--color-base-650);
}
}
</style>

View File

@ -1,10 +1,9 @@
---
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)[];
@ -68,7 +67,12 @@ const { getLocalizedMatch, getLocalizedUrl, formatNumber } = await getI18n(
);
default:
return <ErrorMessage title={`Unknown attribute type: ${type}`} />;
return (
<ErrorMessage
title={`Unknown attribute type: ${type}`}
description="Please contact website technical administrator."
/>
);
}
})
}

View File

@ -1,8 +1,8 @@
---
import type { EndpointAudioPreview } from "src/shared/payload/endpoint-types";
import type { EndpointAudio } from "src/shared/payload/payload-sdk";
interface Props {
audio: EndpointAudioPreview;
audio: EndpointAudio;
class?: string | undefined;
}

View File

@ -1,8 +1,13 @@
---
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;
@ -20,6 +25,9 @@ const { block, lang } = Astro.props;
) : isBlockCueBlock(block) ? (
<CueBlock block={block} lang={lang} />
) : (
<ErrorMessage title={`Unknown block type: ${block.blockType}`} />
<ErrorMessage
title={`Unknown block type: ${block.blockType}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -1,6 +1,6 @@
---
import RichText from "components/RichText/RichText.astro";
import type { CueBlock } from "src/shared/payload/blocks";
import type { CueBlock } from "src/shared/payload/payload-sdk";
interface Props {
block: CueBlock;

View File

@ -1,6 +1,6 @@
---
import RichText from "components/RichText/RichText.astro";
import type { LineBlock } from "src/shared/payload/blocks";
import type { LineBlock } from "src/shared/payload/payload-sdk";
interface Props {
block: LineBlock;

View File

@ -5,21 +5,20 @@ interface Props {
id?: string;
title?: string | undefined;
icon?: string;
class?: string;
ariaLabel?: string;
class?: string | undefined;
}
const { title, icon, ariaLabel, id, ...otherProps } = Astro.props;
const { title, icon, class: className, ariaLabel, id } = Astro.props;
---
{/* ------------------------------------------- HTML ------------------------------------------- */}
<button
id={id}
class:list={["pressable", { "with-title": !!title }, otherProps.class]}
class:list={["pressable", { "with-title": !!title }, className]}
aria-label={ariaLabel}
title={ariaLabel}
{...otherProps.class ? otherProps : {}}>
title={ariaLabel}>
{icon && <Icon name={icon} />}
{title}
</button>

View File

@ -1,5 +1,5 @@
---
import type { EndpointCredit } from "src/shared/payload/endpoint-types";
import type { EndpointCredit } from "src/shared/payload/payload-sdk";
import Metadata from "./Metadata.astro";
import { getI18n } from "src/i18n/i18n";
@ -30,7 +30,6 @@ const { getLocalizedMatch, getLocalizedUrl } = await getI18n(Astro.locals.curren
);
})
}
<slot />
</div>
{/* ------------------------------------------- CSS -------------------------------------------- */}
@ -38,6 +37,6 @@ const { getLocalizedMatch, getLocalizedUrl } = await getI18n(Astro.locals.curren
<style>
div {
display: grid;
gap: 1.5em;
gap: 2em;
}
</style>

View File

@ -5,27 +5,18 @@ import Button from "./Button.astro";
interface Props {
href: string;
filename: string;
useBlob?: boolean;
}
const { href, filename, useBlob = false } = Astro.props;
const { href, filename } = Astro.props;
const { t } = await getI18n(Astro.locals.currentLocale);
---
{/* ------------------------------------------- HTML ------------------------------------------- */}
{
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>
)
}
<download-button href={href} filename={filename} class="when-js when-no-print">
<Button title={t("global.downloadButton")} icon="material-symbols:download" />
</download-button>
{/* ------------------------------------------- JS --------------------------------------------- */}
@ -41,11 +32,11 @@ const { t } = await getI18n(Astro.locals.currentLocale);
elem.addEventListener("click", async () => {
const res = await fetch(href);
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const blobURL = window.URL.createObjectURL(blob);
var link = document.createElement("a");
link.download = filename;
link.href = url;
link.href = blobURL;
link.click();
link.remove();
});

View File

@ -1,14 +1,11 @@
---
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;
---
@ -17,19 +14,7 @@ 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>
) : (
<p>
Please contact{" "}
<a href={getLocalizedUrl("/discord")} target="_blank">
website technical administrator
</a>
.
</p>
)
}
{description && <p>{description}</p>}
</div>
{/* ------------------------------------------- CSS -------------------------------------------- */}

View File

@ -1,10 +1,9 @@
---
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)[];
@ -68,7 +67,12 @@ const { getLocalizedMatch, getLocalizedUrl, formatNumber } = await getI18n(
);
default:
return <ErrorMessage title={`Unknown attribute type: ${type}`} />;
return (
<ErrorMessage
title={`Unknown attribute type: ${type}`}
description="Please contact website technical administrator."
/>
);
}
})
}

View File

@ -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,15 +23,17 @@ const { getLocalizedMatch } = await getI18n(Astro.locals.currentLocale);
/>
))
}
<slot />
</div>
{/* ------------------------------------------- CSS -------------------------------------------- */}
<style>
div {
display: flex;
flex-direction: column;
gap: 0.5em;
display: grid;
gap: 2em;
@media (max-width: 35rem) {
gap: 3em;
}
}
</style>

View File

@ -0,0 +1,67 @@
---
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>

View File

@ -1,4 +1,11 @@
---
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";
@ -7,18 +14,11 @@ 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 | EndpointPayloadImage;
image: EndpointImage | EndpointScanImage | EndpointMediaThumbnail;
pretitle?: string | undefined;
title: string;
subtitle?: string | undefined;
@ -59,7 +59,8 @@ const hasNavigation = previousImageHref || nextImageHref;
<a
id="previous-button"
class:list={{ hidden: !previousImageHref, pressable: true }}
href={previousImageHref}>
href={previousImageHref}
data-astro-history="replace">
<Icon name="material-symbols:chevron-left" />
</a>
)
@ -80,7 +81,8 @@ const hasNavigation = previousImageHref || nextImageHref;
<a
id="next-button"
class:list={{ hidden: !nextImageHref, pressable: true }}
href={nextImageHref}>
href={nextImageHref}
data-astro-history="replace">
<Icon name="material-symbols:chevron-right" />
</a>
)
@ -108,7 +110,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} useBlob />}
{filename && <DownloadButton href={url} filename={filename} />}
</div>
</div>

View File

@ -18,10 +18,8 @@ const { href, method = "get", class: className, id } = Astro.props;
{/* ------------------------------------------- CSS -------------------------------------------- */}
<style>
@media (scripting: enabled) {
maso-actor {
cursor: pointer;
}
maso-actor {
cursor: pointer;
}
</style>

View File

@ -1,7 +1,6 @@
---
import type { Attribute } from "src/utils/attributes";
import TitleIcon from "./TitleIcon.astro";
import { isExternalLink } from "src/utils/urls";
interface Props extends Attribute {}
@ -16,23 +15,15 @@ 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 }) => {
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>
);
}
})
values.map(({ name, href, lang }) =>
href ? (
<a class="pressable" href={href} lang={lang}>
{name}
</a>
) : (
<div lang={lang}>{name}</div>
)
)
}
</div>
</div>
@ -50,7 +41,7 @@ if (values.length === 0) return;
flex-wrap: wrap;
gap: 6px;
place-items: center;
translate: 0px 2px;
translate: 0px 3px;
&.with-border {
& > div,

View File

@ -1,10 +1,10 @@
---
import GenericPreview from "components/Previews/GenericPreview.astro";
import { getI18n } from "src/i18n/i18n";
import type { EndpointAudioPreview } from "src/shared/payload/endpoint-types";
import type { EndpointAudio } from "src/shared/payload/payload-sdk";
interface Props {
audio: EndpointAudioPreview;
audio: EndpointAudio;
}
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.collections.audios", { count: 1 })}
iconHoverLabel={t("global.previewTypes.audio")}
smallTitle={title === filename}
/>

View File

@ -1,48 +0,0 @@
---
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
/>

View File

@ -1,16 +1,16 @@
---
import GenericPreview from "components/Previews/GenericPreview.astro";
import { getI18n } from "src/i18n/i18n";
import { formatPriceForPostProcessing } from "src/middleware/postProcessing";
import type { EndpointCollectiblePreview } from "src/shared/payload/endpoint-types";
import type { EndpointCollectible } from "src/shared/payload/payload-sdk";
import type { Attribute } from "src/utils/attributes";
import { convert } from "src/utils/currencies";
import { formatLocale } from "src/utils/format";
interface Props {
collectible: EndpointCollectiblePreview;
collectible: EndpointCollectible;
}
const { getLocalizedMatch, getLocalizedUrl, t, formatDate } = await getI18n(
const { getLocalizedMatch, getLocalizedUrl, t, formatPrice, formatDate } = await getI18n(
Astro.locals.currentLocale
);
@ -41,13 +41,18 @@ 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: formatPriceForPostProcessing(price, "short"),
},
{ name: price.amount === 0 ? t("collectibles.price.free") : formatPrice(convertedPrice) },
],
withBorder: false,
});
@ -65,6 +70,6 @@ if (price) {
href={getLocalizedUrl(`/collectibles/${slug}`)}
attributes={[...attributes, ...additionalAttributes]}
icon="material-symbols:category"
iconHoverLabel={t("global.collections.collectibles", { count: 1 })}
iconHoverLabel={t("global.previewTypes.collectible")}
disableRoundedTop
/>

View File

@ -1,71 +0,0 @@
---
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}
/>

View File

@ -1,58 +0,0 @@
---
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
/>

View File

@ -1,4 +1,11 @@
---
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";
@ -6,16 +13,9 @@ 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?: EndpointImagePreview | EndpointPayloadImage | EndpointScanImage | undefined;
thumbnail?: EndpointImage | EndpointMediaThumbnail | EndpointScanImage | undefined;
pretitle?: string | undefined;
title: string;
subtitle?: string | undefined;
@ -113,18 +113,8 @@ for (const attribute of attributes) {
<div id="footer">
{
smallTitle ? (
<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 class="font-l" lang={lang}>
{title}
</p>
) : (
<p lang={lang}>
@ -147,7 +137,9 @@ for (const attribute of attributes) {
clippedAttributes.length > 0 && (
<>
{subtitle && <hr />}
<InlineAttributes attributes={clippedAttributes} />
<div id="tags">
<InlineAttributes attributes={clippedAttributes} />
</div>
</>
)
}
@ -175,21 +167,21 @@ for (const attribute of attributes) {
height: auto;
&.rounded-top {
border-top-left-radius: 14px;
border-top-right-radius: 14px;
border-top-left-radius: 1em;
border-top-right-radius: 1em;
}
}
& > #icon-container {
&.thumbnail-alt {
margin: 6px;
margin: 0.4em;
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: 8px;
border-radius: 0.7em;
& > svg {
width: 64px;
@ -199,18 +191,18 @@ for (const attribute of attributes) {
&:not(.thumbnail-alt) {
position: absolute;
top: 6px;
left: 6px;
padding: 8px;
top: 0.4em;
left: 0.4em;
padding: 0.5em;
backdrop-filter: blur(5px);
background-color: color-mix(in srgb, var(--color-elevation-2) 60%, transparent);
}
&.rounded-top {
border-radius: 8px;
border-radius: 0.7em;
}
border-bottom-right-radius: 8px;
border-bottom-right-radius: 0.7em;
}
& > #footer {

View File

@ -1,10 +1,10 @@
---
import GenericPreview from "components/Previews/GenericPreview.astro";
import { getI18n } from "src/i18n/i18n";
import type { EndpointImagePreview } from "src/shared/payload/endpoint-types";
import type { EndpointImage } from "src/shared/payload/payload-sdk";
interface Props {
image: EndpointImagePreview;
image: EndpointImage;
}
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.collections.images", { count: 1 })}
iconHoverLabel={t("global.previewTypes.image")}
smallTitle={title === filename}
/>

View File

@ -1,11 +1,11 @@
---
import GenericPreview from "components/Previews/GenericPreview.astro";
import { getI18n } from "src/i18n/i18n";
import type { EndpointPagePreview } from "src/shared/payload/endpoint-types";
import type { EndpointPage } from "src/shared/payload/payload-sdk";
import type { Attribute } from "src/utils/attributes";
interface Props {
page: EndpointPagePreview;
page: EndpointPage;
}
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.collections.pages", { count: 1 })}
iconHoverLabel={t("global.previewTypes.page")}
/>

View File

@ -1,36 +0,0 @@
---
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 })}
/>

View File

@ -1,10 +1,10 @@
---
import GenericPreview from "components/Previews/GenericPreview.astro";
import { getI18n } from "src/i18n/i18n";
import type { EndpointVideoPreview } from "src/shared/payload/endpoint-types";
import type { EndpointVideo } from "src/shared/payload/payload-sdk";
interface Props {
video: EndpointVideoPreview;
video: EndpointVideo;
}
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.collections.videos", { count: 1 })}
iconHoverLabel={t("global.previewTypes.video")}
smallTitle={title === filename}
/>

View File

@ -1,77 +0,0 @@
---
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>

View File

@ -1,140 +0,0 @@
---
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>

View File

@ -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;

View File

@ -2,14 +2,14 @@
import type { RichTextContext } from "src/utils/richText";
import RTSection from "./components/RTSection.astro";
import RTTranscript from "./components/RTTranscript.astro";
import ErrorMessage from "components/ErrorMessage.astro";
import RTBreak from "./components/RTBreak.astro";
import {
type RichTextBlockNode,
isBlockNodeBreakBlock,
isBlockNodeSectionBlock,
isBlockNodeTranscriptBlock,
isBlockNodeBreakBlock,
} from "src/shared/payload/rich-text";
type RichTextBlockNode,
} from "src/shared/payload/payload-sdk";
import ErrorMessage from "components/ErrorMessage.astro";
import RTBreak from "./components/RTBreak.astro";
interface Props {
node: RichTextBlockNode;
@ -29,6 +29,9 @@ const { node, context } = Astro.props;
) : isBlockNodeBreakBlock(node) ? (
<RTBreak node={node} context={context} />
) : (
<ErrorMessage title={`Unknown block type: ${node.fields.blockType}`} />
<ErrorMessage
title={`Unknown block type: ${node.fields.blockType}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -1,8 +1,7 @@
---
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;
@ -30,7 +29,10 @@ const { node } = Astro.props;
) : node.fields.type === BreakBlockType.solidLine ? (
<hr id={node.anchorHash} class="solid" />
) : (
<ErrorMessage title={`Unknown break block type: ${node.fields.type}`} />
<ErrorMessage
title={`Unknown break block type: ${node.fields.type}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -1,7 +1,7 @@
---
import HeaderTitle from "components/HeaderTitle.astro";
import RichText from "components/RichText/RichText.astro";
import type { RichTextSectionBlock } from "src/shared/payload/rich-text";
import type { RichTextSectionBlock } from "src/shared/payload/payload-sdk";
import type { RichTextContext } from "src/utils/richText";
interface Props {

View File

@ -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;

View File

@ -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;

View File

@ -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/rich-text";
} from "src/shared/payload/payload-sdk";
import ErrorMessage from "components/ErrorMessage.astro";
interface Props {
node: RichTextLinkNode;
@ -34,6 +34,9 @@ const { node, context } = Astro.props;
))}
</RTInternalLink>
) : (
<ErrorMessage title={`Unknown link type: ${node.fields.linkType}`} />
<ErrorMessage
title={`Unknown link type: ${node.fields.linkType}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -1,7 +1,7 @@
---
import ErrorMessage from "components/ErrorMessage.astro";
import { getI18n } from "src/i18n/i18n";
import { Collections } from "src/shared/payload/constants";
import { Collections } from "src/shared/payload/payload-sdk";
interface Props {
doc: {
@ -30,6 +30,9 @@ const { getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
<slot />
</a>
) : (
<ErrorMessage title={`Unknown internal link: ${doc.relationTo}`} />
<ErrorMessage
title={`Unknown internal link: ${doc.relationTo}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -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,
} from "src/shared/payload/rich-text";
isListNodeNumberListNode,
type RichTextListNode,
} from "src/shared/payload/payload-sdk";
import ErrorMessage from "components/ErrorMessage.astro";
interface Props {
node: RichTextListNode;
@ -40,6 +40,9 @@ const { node, context } = Astro.props;
))}
</ul>
) : (
<ErrorMessage title={`Unknown list link: ${node.listType}`} />
<ErrorMessage
title={`Unknown list link: ${node.listType}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -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,6 +49,9 @@ const { node, context } = Astro.props;
) : isNodeUploadNode(node) ? (
<RTUpload node={node} context={context} />
) : (
<ErrorMessage title={`Unknown node type: ${node.type}`} />
<ErrorMessage
title={`Unknown node type: ${node.type}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -1,7 +1,7 @@
---
import type { RichTextContext } from "src/utils/richText";
import RTNode from "./RTNode.astro";
import type { RichTextParagraphNode } from "src/shared/payload/rich-text";
import type { RichTextParagraphNode } from "src/shared/payload/payload-sdk";
interface Props {
node: RichTextParagraphNode;

View File

@ -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/rich-text";
import type { RichTextTextNode } from "src/shared/payload/payload-sdk";
interface Props {
node: RichTextTextNode;

View File

@ -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,6 +29,9 @@ const { node, context } = Astro.props;
) : isUploadNodeVideoNode(node) ? (
<RTVideo node={node} context={context} />
) : (
<ErrorMessage title={`Unknown upload collection: ${node.relationTo}`} />
<ErrorMessage
title={`Unknown upload collection: ${node.relationTo}`}
description="Please contact website technical administrator."
/>
)
}

View File

@ -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;

View File

@ -1,10 +1,11 @@
---
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;
@ -46,6 +47,7 @@ const mediaPage = getLocalizedUrl(`/images/${id}`);
loading="lazy"
/>
</a>
<OpenMediaPageButton url={mediaPage} />
</div>
{/* ------------------------------------------- CSS -------------------------------------------- */}
@ -53,13 +55,11 @@ 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,6 +71,7 @@ const mediaPage = getLocalizedUrl(`/images/${id}`);
&:hover,
&:focus-visible {
scale: 102%;
margin-bottom: 1em;
}
max-height: 70vh;

View File

@ -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;

View File

@ -0,0 +1,74 @@
---
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>

View File

@ -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[];

View File

@ -1,6 +1,6 @@
---
import { getI18n } from "src/i18n/i18n";
import type { TableOfContentEntry } from "src/shared/payload/endpoint-types";
import type { TableOfContentEntry } from "src/shared/payload/payload-sdk";
interface Props {
entry: TableOfContentEntry;

View File

@ -1,9 +1,9 @@
---
import type { EndpointVideoPreview } from "src/shared/payload/endpoint-types";
import type { EndpointVideo } from "src/shared/payload/payload-sdk";
import { formatLocale } from "src/utils/format";
interface Props {
video: EndpointVideoPreview;
video: EndpointVideo;
class?: string | undefined;
}

4
src/env.d.ts vendored
View File

@ -4,6 +4,8 @@
declare namespace App {
interface Locals {
currentLocale: string;
pageCaching: boolean;
currentTheme: "dark" | "auto" | "light";
currentCurrency: string;
notFound: boolean;
}
}

View File

@ -1,16 +1,12 @@
import type { WordingKey } from "src/i18n/wordings-keys";
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";
import type { ChronologyEvent, EndpointSource } from "src/shared/payload/payload-sdk";
import { cache } from "src/utils/payload";
import { capitalize, formatInlineTitle } from "src/utils/format";
export const defaultLocale = "en";
export type I18n = Awaited<ReturnType<typeof getI18n>>;
export const getI18n = async (locale: string) => {
const formatWithValues = (
templateName: string,
@ -119,7 +115,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 = contextCache.wordings.find(({ name }) => name === key);
const wording = cache.wordings.find(({ name }) => name === key);
const fallbackString = `«${key}»`;
if (!wording) {
@ -183,7 +179,7 @@ export const getI18n = async (locale: string) => {
return number.toLocaleString(locale, options);
};
const formatTimelineDate = ({ year, month, day }: EndpointChronologyEvent["date"]): string => {
const formatTimelineDate = ({ year, month, day }: ChronologyEvent["date"]): string => {
const date = new Date(0);
date.setFullYear(year);
if (month) date.setMonth(month - 1);
@ -242,8 +238,8 @@ export const getI18n = async (locale: string) => {
}
};
const formatEndpointRelation = (
relation: EndpointRelation
const formatEndpointSource = (
source: EndpointSource
): {
href: string;
typeLabel: string;
@ -252,107 +248,98 @@ export const getI18n = async (locale: string) => {
target?: string;
rel?: string;
} => {
switch (relation.type) {
case "url":
switch (source.type) {
case "url": {
return {
href: relation.value.url,
href: source.url,
typeLabel: t("global.sources.typeLabel.url"),
label: relation.value.label,
label: source.label,
target: "_blank",
rel: "noopener noreferrer",
};
}
case Collections.Collectibles: {
const getRangeLabel = () => {
switch (relation.range?.type) {
case "collectible": {
const rangeLabel = (() => {
switch (source.range?.type) {
case "timestamp":
return t("global.sources.typeLabel.collectible.range.timestamp", {
page: relation.range.timestamp,
page: source.range.timestamp,
});
case "page":
return t("global.sources.typeLabel.collectible.range.page", {
page: relation.range.page,
page: source.range.page,
});
case "custom":
return t("global.sources.typeLabel.collectible.range.custom", {
note: getLocalizedMatch(relation.range.translations).note,
note: getLocalizedMatch(source.range.translations).note,
});
case undefined:
default:
return "";
}
};
})();
const suffix =
relation.subpage === "scans"
? "/scans"
: relation.subpage === "gallery"
? "/gallery"
: "";
const translation = getLocalizedMatch(relation.value.translations);
const translation = getLocalizedMatch(source.collectible.translations);
return {
href: getLocalizedUrl(`/collectibles/${relation.value.slug}${suffix}`),
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}`),
typeLabel: t("global.sources.typeLabel.collectible"),
label: formatInlineTitle(translation) + getRangeLabel(),
label: formatInlineTitle(translation) + rangeLabel,
lang: translation.language,
};
}
case Collections.Pages: {
const translation = getLocalizedMatch(relation.value.translations);
case "page": {
const translation = getLocalizedMatch(source.page.translations);
return {
href: getLocalizedUrl(`/pages/${relation.value.slug}`),
href: getLocalizedUrl(`/pages/${source.page.slug}`),
typeLabel: t("global.sources.typeLabel.page"),
label: formatInlineTitle(translation),
lang: translation.language,
};
}
case Collections.Folders: {
const translation = getLocalizedMatch(relation.value.translations);
case "folder": {
const translation = getLocalizedMatch(source.folder.translations);
return {
href: getLocalizedUrl(`/folders/${relation.value.slug}`),
href: getLocalizedUrl(`/folders/${source.folder.slug}`),
typeLabel: t("global.sources.typeLabel.folder"),
label: formatInlineTitle(translation),
label: getLocalizedMatch(source.folder.translations).name,
lang: translation.language,
};
}
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;
case "scans": {
const translation = getLocalizedMatch(source.collectible.translations);
return {
href: getLocalizedUrl(`/timeline#${formatTimelineDateToId(relation.value.date)}`),
typeLabel: t("global.sources.typeLabel.timeline"),
label,
href: getLocalizedUrl(`/collectibles/${source.collectible.slug}/scans`),
typeLabel: t("global.sources.typeLabel.scans"),
label: formatInlineTitle(getLocalizedMatch(source.collectible.translations)),
lang: translation.language,
};
}
/* 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:
}
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,
};
}
return {
href: "/404",
label: `Invalid type ${relation["type"]}`,
typeLabel: "Error",
};
default: {
return {
href: "/404",
label: `Invalid type ${source["type"]}`,
typeLabel: "Error",
};
}
}
};
return {
@ -368,7 +355,7 @@ export const getI18n = async (locale: string) => {
formatMillimeters,
formatNumber,
formatTimelineDate,
formatEndpointRelation,
formatEndpointSource,
formatScanIndexShort,
formatFilesize,
};

View File

@ -85,7 +85,7 @@ export type WordingKey =
| "global.loading"
| "pages.tableOfContent.sceneBreak"
| "pages.tableOfContent.break"
| "pages.credits.availableLanguages"
| "global.languageOverride.availableLanguages"
| "timeline.title"
| "timeline.description"
| "timeline.eras.cataclysm"
@ -147,43 +147,4 @@ export type WordingKey =
| "collectibles.nature"
| "collectibles.languages"
| "collectibles.nature.physical"
| "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";
| "collectibles.nature.digital";

View File

@ -1,8 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,8 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,28 +1,233 @@
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";
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 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;
});
export const onRequest = sequence(
// Possible redirect
actionsHandlingMiddleware,
localeNegotiationMiddleware,
removeTrailingSlashMiddleware,
addCommonHeadersMiddleware,
// Get a response
requestTrackingMiddleware,
cookieRefreshingMiddleware,
setAstroLocalsMiddleware,
// Generate body
postProcessingMiddleware,
pageCachingMiddleware
addContentLanguageResponseHeader,
handleActionsSearchParams,
refreshCookiesMaxAge,
localeNegotiator,
provideLocalsToRequest,
analytics
);
/* 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;
};

View File

@ -1,58 +0,0 @@
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();
});

View File

@ -1,25 +0,0 @@
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;
});

View File

@ -1,55 +0,0 @@
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;
});

View File

@ -1,38 +0,0 @@
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();
});

View File

@ -1,34 +0,0 @@
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;
});

View File

@ -1,99 +0,0 @@
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("&quot;", '"');
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("&#34;", '"');
const currency = JSON.parse(unescapedString) as PostProcessingCurrency;
if (currentCurrency === currency.currency) {
return "current";
} else {
return "";
}
});
return new Response(html, response);
});

View File

@ -1,10 +0,0 @@
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();
});

View File

@ -1,13 +0,0 @@
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;
});

View File

@ -1,8 +0,0 @@
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();
});

View File

@ -1,72 +0,0 @@
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