Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab9e4857a4 | ||
|
|
bd95bacfc5 |
@@ -10,3 +10,8 @@ public/uploads/
|
|||||||
|
|
||||||
# Session scratch
|
# Session scratch
|
||||||
.tmp/
|
.tmp/
|
||||||
|
|
||||||
|
# Local secrets and editor state
|
||||||
|
.env*
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ presentation. Pages are `.astro` templates (plain HTML).
|
|||||||
## Brand (official: CTAO Brand Guidelines, April 2024 — PDF in the project docs)
|
## Brand (official: CTAO Brand Guidelines, April 2024 — PDF in the project docs)
|
||||||
|
|
||||||
- Colors: Galaxy Blue `#00004A`, Cherenkov Blue `#00E4D8`, Moon Gray
|
- Colors: Galaxy Blue `#00004A`, Cherenkov Blue `#00E4D8`, Moon Gray
|
||||||
`#F5F5F5`, Cosmic Azure `#007AFF` (links/focus), Interstellar Indigo
|
`#F5F5F5`, Cosmic Azure `#007AFF` (focus rings only; links use the
|
||||||
`#00009C`. Cherenkov is an ACCENT (brand book rations it — currently:
|
darkened `--link` so they pass AA on white), Interstellar Indigo `#00009C`.
|
||||||
primary CTA, hero subtitle, logo flash); it fails AA as text on white.
|
Cherenkov is an ACCENT (brand book rations it — currently: primary CTA,
|
||||||
|
hero subtitle, logo flash); it fails AA as text on white.
|
||||||
|
- Comments in the code cite `SPEC §…`, `BRAND D.x` and `REQUIREMENTS`: those
|
||||||
|
are the CTAO specification, the brand guidelines PDF and the team's
|
||||||
|
consolidated requirements, kept in the project's document folder at
|
||||||
|
Cyfronet, not in this repo.
|
||||||
- Type: **Inter** for everything, **Space Grotesk** (weight 500) only for
|
- Type: **Inter** for everything, **Space Grotesk** (weight 500) only for
|
||||||
communicative headlines (h1/h2). Sizes come from the `--fs-*` scale.
|
communicative headlines (h1/h2). Sizes come from the `--fs-*` scale.
|
||||||
- Logo files in `public/brand/`; always "CTAO", min 100px wide, no effects.
|
- Logo files in `public/brand/`; always "CTAO", min 100px wide, no effects.
|
||||||
@@ -29,4 +34,5 @@ presentation. Pages are `.astro` templates (plain HTML).
|
|||||||
(astro.config.mjs) — the default minifier breaks `animation-timeline`.
|
(astro.config.mjs) — the default minifier breaks `animation-timeline`.
|
||||||
- `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading
|
- `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading
|
||||||
progress); change the header height only through the token.
|
progress); change the header height only through the token.
|
||||||
- Deterministic gate before pushing: `npm run check` (build + link check).
|
- Deterministic gate before pushing: `npm run check` (types + build + link
|
||||||
|
check, a few seconds).
|
||||||
|
|||||||
@@ -44,15 +44,23 @@ gitignored here.
|
|||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
|
Prerequisites: Node 22.19 or newer (the machine builds on Node 24), and
|
||||||
|
network access to the demo machine's Gitea (Cyfronet network / VPN) for the
|
||||||
|
content clone below. Without content the site still builds, but with no
|
||||||
|
articles and `npm run check` reports the footer's page links as broken.
|
||||||
|
|
||||||
```
|
```
|
||||||
npm ci
|
npm ci
|
||||||
git clone https://astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git ../ctao-content
|
git clone https://astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git ../ctao-content
|
||||||
./scripts/link-content.sh # copies news/pages/uploads into the dev tree
|
./scripts/link-content.sh # copies news/pages/uploads into the dev tree
|
||||||
npm run dev # http://localhost:4321
|
npm run dev # http://localhost:4321
|
||||||
npm run build # static output in dist/
|
npm run build # static output in dist/
|
||||||
npm run check # deterministic gate: build + internal-link check
|
npm run check # deterministic gate: astro check (types) + build + internal-link check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`tsconfig.json` excludes `public/` on purpose: the vendored CMS bundle there is
|
||||||
|
2 MB of minified JS and would make `astro check` run out of memory.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
@@ -76,3 +84,17 @@ Saving commits to `main`; the machine polls and republishes automatically
|
|||||||
Note for styling work: `public/admin/preview.css` mirrors the `.prose` rules
|
Note for styling work: `public/admin/preview.css` mirrors the `.prose` rules
|
||||||
from `global.css` so the editor preview matches the site 1:1 — keep them in
|
from `global.css` so the editor preview matches the site 1:1 — keep them in
|
||||||
sync (both files carry a KEEP IN SYNC comment).
|
sync (both files carry a KEEP IN SYNC comment).
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
Versions are pinned exactly; an upgrade is a deliberate change, never a side
|
||||||
|
effect of `npm install`. Astro ships security fixes only for the current and
|
||||||
|
one previous major, so plan one upgrade per major rather than skipping several.
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @astrojs/upgrade # moves astro and official integrations together
|
||||||
|
npm run check # types + build + links must pass
|
||||||
|
npm run preview # verify the real dist/ output, not the dev server
|
||||||
|
```
|
||||||
|
|
||||||
|
No experimental flags in `astro.config.mjs`: they can change in minor releases.
|
||||||
|
|||||||
+8
-6
@@ -6,12 +6,19 @@ export default defineConfig({
|
|||||||
// Public origin — used to build absolute URLs (canonical, og:*, RSS,
|
// Public origin — used to build absolute URLs (canonical, og:*, RSS,
|
||||||
// sitemap). Change when the portal moves to its production domain.
|
// sitemap). Change when the portal moves to its production domain.
|
||||||
site: 'https://astro.isl-dev.grid.cyfronet.pl',
|
site: 'https://astro.isl-dev.grid.cyfronet.pl',
|
||||||
|
// URL contract, pinned explicitly (these are the defaults): changing either
|
||||||
|
// later rewrites every public URL. nginx serves /path/ via try_files $uri/.
|
||||||
|
trailingSlash: 'ignore',
|
||||||
|
build: { format: 'directory' },
|
||||||
|
// Lossless whitespace handling instead of the v7 default 'jsx', which drops
|
||||||
|
// line breaks around inline elements and silently glues words to links when
|
||||||
|
// a template line wraps before <a>. Templates render as written.
|
||||||
|
compressHTML: true,
|
||||||
// Built-in prefetch on every internal link (no per-link attributes needed).
|
// Built-in prefetch on every internal link (no per-link attributes needed).
|
||||||
// Default 'hover' strategy: near-instant navigation without the bandwidth
|
// Default 'hover' strategy: near-instant navigation without the bandwidth
|
||||||
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
|
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
|
||||||
// data-saver / slow connections (Astro handles that).
|
// data-saver / slow connections (Astro handles that).
|
||||||
prefetch: { prefetchAll: true },
|
prefetch: { prefetchAll: true },
|
||||||
server: { port: 4321, host: true },
|
|
||||||
vite: {
|
vite: {
|
||||||
build: {
|
build: {
|
||||||
// Lightning CSS (the default minifier) illegally folds animation-timeline
|
// Lightning CSS (the default minifier) illegally folds animation-timeline
|
||||||
@@ -20,10 +27,5 @@ export default defineConfig({
|
|||||||
// esbuild minifies without property merging.
|
// esbuild minifies without property merging.
|
||||||
cssMinify: 'esbuild',
|
cssMinify: 'esbuild',
|
||||||
},
|
},
|
||||||
server: {
|
|
||||||
// Vite blocks unknown Host headers by default; the demo is viewed through an
|
|
||||||
// ephemeral cloudflared quick tunnel, so allow any *.trycloudflare.com host.
|
|
||||||
allowedHosts: ['.trycloudflare.com'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
# Deployment log — CTAO portal demo on strapi-experimental.cyfronet
|
|
||||||
|
|
||||||
Living document: what was done, what broke, what is left. Update it as work
|
|
||||||
proceeds so any agent (or human) can resume without the chat history.
|
|
||||||
Started 2026-07-28.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Run the git-based CMS demo (Gitea + Sveltia + Astro static build) on the
|
|
||||||
Cyfronet test machine so the SUSS-PORT team can click through it. Shape must
|
|
||||||
be production-viable, not throwaway. Machine footprint: one directory
|
|
||||||
(`~/ctao-portal-demo/`) + 4 unit files, fully removable (README "Uninstall").
|
|
||||||
|
|
||||||
## Ground rules (from the user, non-negotiable)
|
|
||||||
|
|
||||||
- **Write every command out in chat, marked read-only / write, BEFORE running
|
|
||||||
it** — the user reviews on a phone and cannot see truncated tool calls.
|
|
||||||
- Non-invasive verification first, debugging second: confirm each precondition
|
|
||||||
instead of fixing after the fact.
|
|
||||||
- Never read secrets (`~/.ssh/*`, tokens). Mask token URLs in any output
|
|
||||||
(`sed 's|://[^@]*@|://***@|g'`). Secrets stay in 0600 files on the machine.
|
|
||||||
- No sudo, nothing outside `$HOME` on the machine, no traces for the admin.
|
|
||||||
- Temp/working files under `.tmp/<date>-<topic>/` in the repo, never `/tmp`.
|
|
||||||
- Commit messages: **no "Co-Authored-By: Claude"**, no session links.
|
|
||||||
|
|
||||||
## Architecture (decided, see also README.md in this dir)
|
|
||||||
|
|
||||||
```
|
|
||||||
editor → Sveltia (/admin, static) → commit → Gitea :3000 (container, SQLite)
|
|
||||||
│ polled every 10 s
|
|
||||||
build.sh (systemd user timer)
|
|
||||||
└─ podman run node+git, --network=container:ctao-demo-gitea
|
|
||||||
npm ci (only if lockfile changed) → astro build
|
|
||||||
→ releases/<sha>/ → atomic symlink flip
|
|
||||||
│
|
|
||||||
nginx :8080 serves releases/current
|
|
||||||
│ (TODO) ingress vhost → team access
|
|
||||||
```
|
|
||||||
|
|
||||||
## Machine facts (verified 2026-07-28, read-only)
|
|
||||||
|
|
||||||
- Rocky Linux 9.7, host `outline.openstacklocal`, IP 192.168.10.15, 2 vCPU,
|
|
||||||
3.6 GB RAM, no swap. User `strapi`, no sudo, `Linger=yes`, user systemd running.
|
|
||||||
- Podman 5.6.0 rootless. Host has node 24.17, npm, curl, jq, rsync — **no git**,
|
|
||||||
**no nginx** (hence the build container / nginx container).
|
|
||||||
- Neighbours: pod-strapi (`:1337`), Outline (`:9091`), code-server (`127.0.0.1:41787`).
|
|
||||||
- **VPN does NOT route to machine ports** (`curl 192.168.10.15:1337` times out);
|
|
||||||
only the admin-managed HTTPS ingress reaches the machine
|
|
||||||
(`https://strapi.isl-dev.grid.cyfronet.pl` → 204). Hence ports 3000/8080 are
|
|
||||||
reachable for us only via `ssh -L`, and the team needs vhosts from Hubert.
|
|
||||||
|
|
||||||
## DONE
|
|
||||||
|
|
||||||
### 1. Local: files authored + reviewed + tested (before touching the machine)
|
|
||||||
- Wrote quadlets, `build.sh`, systemd service/timer, `nginx.conf`,
|
|
||||||
`Containerfile.build`, `README.md` (runbook incl. uninstall).
|
|
||||||
- **Opus security review** — 8 findings, every one verified personally:
|
|
||||||
BLOCKER `--network=host` (build code could reach host loopback: code-server
|
|
||||||
= RCE, Strapi) → replaced; `OnUnitActiveSec` is start-relative, not
|
|
||||||
end-relative (agent corrected me) → `OnUnitInactiveSec`; missing `mkdir` for
|
|
||||||
`~/.config/*`; nginx-before-first-build race; prune `pipefail` landmine;
|
|
||||||
noisy unit failure when Gitea is down; CORS `SCHEME` note; RAM headroom.
|
|
||||||
All fixed. No hallucinated findings.
|
|
||||||
- **Local end-to-end test** against the local Gitea (`:3010`), sandbox
|
|
||||||
`.tmp/2026-07-28-deploy-test/`: cold build 23 s · no-op poll 45 ms ·
|
|
||||||
incremental rebuild 4 s · prune ✓ · Gitea-down grace ✓ · anonymous clone
|
|
||||||
(no token in `.git/config`) ✓.
|
|
||||||
- Commits (local Gitea repo `ctao/portal`): `ed7a9c0`, `80d886c`.
|
|
||||||
|
|
||||||
### 2. Machine: install steps 1–7 of README
|
|
||||||
- Created `~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config}`
|
|
||||||
and `~/.config/{containers/systemd,systemd/user}`.
|
|
||||||
- Copied unit files, `build.sh`, `nginx.conf`, `Containerfile.build`.
|
|
||||||
- Pulled + **pinned by digest**: gitea 1.27-rootless, nginx stable-alpine,
|
|
||||||
node:24-alpine; built `localhost/ctao-portal-build:1`.
|
|
||||||
- Gitea running (`:3000`, API version 1.27.1), admin `ctao` created with
|
|
||||||
`--random-password` → `~/ctao-portal-demo/state/initial-admin.txt` (0600),
|
|
||||||
push token → `state/push-token.txt` (0600). **Never printed in chat.**
|
|
||||||
- Content pushed via git bundle (78 MB, one-off bootstrap): repo `ctao/portal`
|
|
||||||
at `80d886c`, public, anonymously clonable. Bundle deleted afterwards.
|
|
||||||
- **First build on the machine: 44 s** (`npm ci` ~35 s + astro build 6.5 s),
|
|
||||||
225 pages. Incremental rebuilds should be ~10 s (measure to confirm).
|
|
||||||
- nginx container running, `http://localhost:8080/` → **200, `<title>CTAO
|
|
||||||
Science Portal</title>`**.
|
|
||||||
- Poll timer enabled (`ctao-portal-build.timer`), ticking every ~10 s, journal
|
|
||||||
clean.
|
|
||||||
|
|
||||||
### 3. Problems hit on the machine (and the fixes)
|
|
||||||
1. **Rootless bridge network impossible**: `netavark: modprobe ip_tables:
|
|
||||||
Operation not permitted` → quadlet restart loop. Root cause: kernel module
|
|
||||||
not loaded, rootless cannot modprobe, no sudo. **Fix:** dropped
|
|
||||||
`ctao-demo.network`; build joins Gitea's netns
|
|
||||||
(`--network=container:ctao-demo-gitea`), so it reaches Gitea on
|
|
||||||
`localhost:3000` while the host loopback stays unreachable (pasta).
|
|
||||||
Same security property as the bridge, zero privileges. Lesson written to
|
|
||||||
`.skills/podman-quadlet/SKILL.md`.
|
|
||||||
2. **Push rejected**: admin was created with `--must-change-password` →
|
|
||||||
`remote: Update your password`. **Fix:** `gitea admin user
|
|
||||||
must-change-password --unset ctao`.
|
|
||||||
3. **My rc-chain bug**: `... | sed` made `$?` the sed's status, so a failed
|
|
||||||
push printed "push-ok" and the bundle was deleted prematurely → had to
|
|
||||||
re-upload 78 MB. **Fix:** capture output in a var, check rc of the real
|
|
||||||
command. (Also why the upload "took so long" — it ran twice.)
|
|
||||||
4. **Push-created repo was private** despite `DEFAULT_PRIVATE=public` —
|
|
||||||
push-to-create has its own key. **Fix:** PATCH via API to public +
|
|
||||||
`GITEA__repository__DEFAULT_PUSH_CREATE_PRIVATE=false` in the quadlet.
|
|
||||||
5. **`npm ci` failed: `ETXTBSY` spawning `esbuild`** (postinstall race in a
|
|
||||||
rootless container, ~5 min wasted). **Fix:** `npm ci --ignore-scripts`
|
|
||||||
(also removes the malicious-postinstall vector) + persistent npm cache
|
|
||||||
volume `~/ctao-portal-demo/npm-cache`. Validated locally, then on machine.
|
|
||||||
|
|
||||||
### 4. Left on the machine (known, not cleaned — user asked to be told, not tidied)
|
|
||||||
- Orphan systemd entry `ctao-demo-network.service` (not-found/active-exited),
|
|
||||||
ghost of the removed network quadlet. Harmless; clears with
|
|
||||||
`systemctl --user stop ctao-demo-network.service`.
|
|
||||||
- `/tmp/storage-run-1003/` (~136 KB) — podman runroot from ssh calls without
|
|
||||||
`XDG_RUNTIME_DIR`. Disappears on reboot. (`storage-run-1001` is NOT ours.)
|
|
||||||
- Red journal entries from the netavark restart loop (history, not a live fault).
|
|
||||||
- Disk: 11 G → 9.6 G free (images ~590 MB + repo/content). RAM available ~1.3 G.
|
|
||||||
|
|
||||||
### 5. Pipeline proven end-to-end on the machine (2026-07-28)
|
|
||||||
- Pushed `46ec5e2` to the machine's Gitea (incremental bundle, 6 KB — this is
|
|
||||||
what ongoing syncs cost, vs the 78 MB one-off bootstrap).
|
|
||||||
- **The poller published it automatically in 26 s end-to-end** (push →
|
|
||||||
visible), of which the build itself was 21 s. That is the demo's publish
|
|
||||||
latency: **~30 s worst case** on 2 vCPU (vs 44 s for the very first build
|
|
||||||
which included `npm ci`). Cold-cache builds only happen when the lockfile
|
|
||||||
changes.
|
|
||||||
- Verified after publish: `/` 200, `/admin/` 200 (Sveltia), an article page
|
|
||||||
200; neighbours untouched (Strapi 204, Outline 200); RAM available 1.2 G,
|
|
||||||
disk free 9.0 G.
|
|
||||||
|
|
||||||
### 6. Sveltia wired to the machine + editor cycle proven (2026-07-28)
|
|
||||||
- **OAuth app created via API, no UI clicking**: temp `write:user` token from
|
|
||||||
`gitea admin user generate-access-token` (CLI in container) → POST
|
|
||||||
`/user/applications/oauth2` → temp token deleted (needed basic auth from
|
|
||||||
`initial-admin.txt`; token-auth DELETE returns 401). App: "Sveltia CMS
|
|
||||||
(demo)", `client_id cf1c44ac-ebe6-4a97-bf2f-f7f26ef1126c`, PKCE
|
|
||||||
(`confidential_client=false`), redirect_uris: `http://localhost:18080/admin/`
|
|
||||||
and `http://localhost:8080/admin/`.
|
|
||||||
- **Found & fixed**: `public/admin/config.yml` still pointed at the MAC's Gitea
|
|
||||||
(`localhost:3010` + old app_id) — CMS login on the machine could never work.
|
|
||||||
Switched to `localhost:3000` + machine app_id (`bc02012`). localhost:3000 is
|
|
||||||
valid both on the machine and through the tunnel (same-port trick).
|
|
||||||
- **Editor cycle simulated exactly like Sveltia does it** (Gitea contents API):
|
|
||||||
create article via API → **live on the portal in 12 s**; delete via API →
|
|
||||||
**gone in 21 s**. Repo left clean (`29385a5`). Earlier full-push rebuild:
|
|
||||||
16 s build. Publish latency ≈ **10–30 s** depending on poll-tick alignment.
|
|
||||||
- Tunnel run from the Mac with keepalive (`-o ServerAliveInterval=30`), portal
|
|
||||||
mapped to **18080** locally (user request: non-default port), Gitea kept at
|
|
||||||
**3000** (must match ROOT_URL). First tunnel died after idle — keepalive fixed.
|
|
||||||
- Push token scope confirmed minimal (`write:repository` only) — cannot manage
|
|
||||||
users/apps; file format is `<name>:<40-hex>` (grep the hex out).
|
|
||||||
|
|
||||||
## TODO (next agent starts here)
|
|
||||||
|
|
||||||
1. ~~Commit the `--ignore-scripts` fix and mirror to the machine~~ — DONE
|
|
||||||
(`46ec5e2`, and it doubled as the live pipeline test above).
|
|
||||||
2. ~~Measure incremental publish latency on the machine~~ — DONE: **26 s
|
|
||||||
end-to-end**, build 21 s.
|
|
||||||
3. ~~Verify the loop through `ssh -L`~~ — mostly DONE: tunnel
|
|
||||||
`-L 18080:localhost:8080 -L 3000:localhost:3000` (with ServerAlive
|
|
||||||
keepalive) verified with real content. **Remaining: the user clicks
|
|
||||||
"Sign in with Gitea" at `http://localhost:18080/admin/`** and edits an
|
|
||||||
article in the browser — the only step that needs a human + browser
|
|
||||||
(PKCE login). Credentials: `initial-admin.txt` on the machine.
|
|
||||||
4. ~~Sveltia OAuth app~~ — DONE via API (see §6). Config committed and
|
|
||||||
auto-published. Only the browser login test remains (see 3).
|
|
||||||
5. **Message to Hubert** (only after we have seen it working):
|
|
||||||
- two ingress vhosts → `192.168.10.15:8080` (portal) and `:3000` (gitea),
|
|
||||||
same mechanism as `strapi.isl-dev…:1337`;
|
|
||||||
- are vhosts public-internet or VPN-scopable? (Gitea preferably VPN-only);
|
|
||||||
- does Cyfronet offer static-file hosting on the ingress itself? If yes we
|
|
||||||
drop our nginx container and just rsync builds there.
|
|
||||||
6. **After vhosts arrive**: replace both `TODO(vhost)` in
|
|
||||||
`ctao-demo-gitea.container` (ROOT_URL, CORS origin + `GITEA__cors__SCHEME=https`),
|
|
||||||
`daemon-reload` + restart, update Sveltia config, re-test login.
|
|
||||||
7. **Backup**: nightly `tar` of `~/ctao-portal-demo/gitea-data` (the only
|
|
||||||
stateful thing; everything else is rebuildable). Not yet written.
|
|
||||||
8. **Decided but not implemented — split into two repos** (`portal` = code,
|
|
||||||
`portal-content` = markdown + uploads). Rationale: editors get write access
|
|
||||||
ONLY to content, so repo-push cannot alter `package.json`/templates that the
|
|
||||||
build executes; also keeps code history clean. Cost: `build.sh` clones two
|
|
||||||
repos and overlays content (~10 lines), polls two SHAs. Do this for the
|
|
||||||
production version, not mid-demo.
|
|
||||||
9. Demo choreography: the test article is `draft: true`; the user creates one
|
|
||||||
live during the demo. With the poller running, a Sveltia save publishes
|
|
||||||
automatically — no manual rebuild needed (unlike the Mac preview setup).
|
|
||||||
|
|
||||||
## Command cheat-sheet (machine)
|
|
||||||
|
|
||||||
```
|
|
||||||
export XDG_RUNTIME_DIR=/run/user/$(id -u) # needed for systemctl --user over ssh
|
|
||||||
systemctl --user status ctao-demo-gitea ctao-demo-web ctao-portal-build.timer
|
|
||||||
journalctl --user -u ctao-portal-build -n 20 --no-pager # build times land here
|
|
||||||
~/ctao-portal-demo/bin/build.sh # manual build
|
|
||||||
readlink ~/ctao-portal-demo/releases/current # which sha is live
|
|
||||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/
|
|
||||||
```
|
|
||||||
@@ -40,7 +40,7 @@ Environment=GITEA__service__DISABLE_REGISTRATION=true
|
|||||||
Environment=GITEA__mailer__ENABLED=false
|
Environment=GITEA__mailer__ENABLED=false
|
||||||
# Repos default to public so the build pipeline can clone anonymously —
|
# Repos default to public so the build pipeline can clone anonymously —
|
||||||
# content is the public site anyway. The `ctao/portal` repo itself is
|
# content is the public site anyway. The `ctao/portal` repo itself is
|
||||||
# created in the UI (README step 6).
|
# created in the UI (README step 7).
|
||||||
Environment=GITEA__repository__DEFAULT_PRIVATE=public
|
Environment=GITEA__repository__DEFAULT_PRIVATE=public
|
||||||
# Sveltia is served from the portal vhost and calls the Gitea API cross-origin
|
# Sveltia is served from the portal vhost and calls the Gitea API cross-origin
|
||||||
# — CORS locked to exactly that origin. ALLOW_DOMAIN takes FULL origins with
|
# — CORS locked to exactly that origin. ALLOW_DOMAIN takes FULL origins with
|
||||||
|
|||||||
Generated
+10
-10
@@ -8,11 +8,11 @@
|
|||||||
"name": "ctao-portal",
|
"name": "ctao-portal",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^7.2.10"
|
"astro": "7.2.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.10",
|
"@astrojs/check": "0.9.10",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@astrojs/check": {
|
"node_modules/@astrojs/check": {
|
||||||
@@ -3591,9 +3591,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3791,9 +3791,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.22",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
|
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -3810,7 +3810,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.16",
|
"nanoid": "^3.3.18",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-4
@@ -7,13 +7,16 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"check": "astro build && node scripts/check-links.mjs"
|
"check": "astro check && astro build && node scripts/check-links.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^7.2.10"
|
"astro": "7.2.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.10",
|
"@astrojs/check": "0.9.10",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 640" role="img" aria-label="Architektura klasycznego CMS z bazą danych: aplikacja CMS z publicznym panelem admina działa non stop, obok baza danych i wolumen mediów; czytelnik dostaje strony renderowane na żądanie">
|
|
||||||
<defs>
|
|
||||||
<marker id="arrB" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
||||||
<path d="M0 0 L10 5 L0 10 z" fill="#00004a"/>
|
|
||||||
</marker>
|
|
||||||
<filter id="softB" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="5" flood-color="#00004a" flood-opacity="0.10"/>
|
|
||||||
</filter>
|
|
||||||
<style>
|
|
||||||
.t { font-family: Inter, "Segoe UI", Arial, sans-serif; }
|
|
||||||
.box { fill: #ffffff; stroke: #00004a; stroke-width: 1.5; }
|
|
||||||
.sub { fill: #f5f5f5; stroke: #e2e5ee; stroke-width: 1; }
|
|
||||||
.ttl { font-weight: 700; font-size: 15.5px; fill: #00004a; letter-spacing: .04em; }
|
|
||||||
.cap { font-size: 12.5px; fill: #46536a; }
|
|
||||||
.lbl { font-size: 12px; fill: #46536a; paint-order: stroke; stroke: #ffffff; stroke-width: 4; }
|
|
||||||
.chipbg { fill: #e9ebf3; }
|
|
||||||
.chip { font-size: 11.5px; font-weight: 700; fill: #00004a; letter-spacing: .06em; }
|
|
||||||
.flow { stroke: #00004a; stroke-width: 1.8; fill: none; marker-end: url(#arrB); }
|
|
||||||
.pill { fill: #f5f5f5; stroke: #e2e5ee; }
|
|
||||||
.pilltxt { font-size: 12.5px; fill: #101228; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- machine boundary (mirrors the static-demo diagram 1:1) -->
|
|
||||||
<rect x="340" y="40" width="830" height="570" rx="18" fill="#f8f9fc" stroke="#e2e5ee" stroke-width="1.5"/>
|
|
||||||
<text class="t" x="366" y="76" font-size="13" font-weight="700" fill="#00004a" letter-spacing=".18em">MASZYNA · CMS Z BAZĄ DANYCH</text>
|
|
||||||
<text class="t cap" x="366" y="96">np. WordPress, Strapi, Drupal — wspólny schemat tej klasy rozwiązań</text>
|
|
||||||
|
|
||||||
<!-- actor: redaktor -->
|
|
||||||
<g filter="url(#softB)">
|
|
||||||
<rect class="box" x="52" y="120" width="220" height="120" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<circle cx="88" cy="156" r="11" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<path d="M72 186 q16 -18 32 0" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<text class="t ttl" x="116" y="162">Redaktor</text>
|
|
||||||
<text class="t cap" x="70" y="212">panel admina w przeglądarce</text>
|
|
||||||
<text class="t cap" x="70" y="228">logowanie do aplikacji</text>
|
|
||||||
|
|
||||||
<!-- actor: czytelnik -->
|
|
||||||
<g filter="url(#softB)">
|
|
||||||
<rect class="box" x="52" y="400" width="220" height="120" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<circle cx="88" cy="436" r="11" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<path d="M72 466 q16 -18 32 0" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<text class="t ttl" x="116" y="442">Czytelnik</text>
|
|
||||||
<text class="t cap" x="70" y="492">publiczna strona portalu</text>
|
|
||||||
<text class="t cap" x="70" y="508">HTML składany na żądanie</text>
|
|
||||||
|
|
||||||
<!-- CMS application (tall box, always running) -->
|
|
||||||
<g filter="url(#softB)">
|
|
||||||
<rect class="box" x="400" y="120" width="320" height="400" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<text class="t ttl" x="424" y="152">APLIKACJA CMS</text>
|
|
||||||
<rect class="chipbg" x="590" y="136" width="106" height="22" rx="11"/>
|
|
||||||
<text class="t chip" x="643" y="151" text-anchor="middle">NON STOP</text>
|
|
||||||
<rect class="sub" x="420" y="176" width="280" height="88" rx="12"/>
|
|
||||||
<text class="t" x="436" y="202" font-size="13.5" font-weight="700" fill="#101228">panel admina</text>
|
|
||||||
<text class="t cap" x="436" y="224">publiczny adres + logowanie</text>
|
|
||||||
<text class="t cap" x="436" y="242">z internetu</text>
|
|
||||||
<rect class="sub" x="420" y="278" width="280" height="88" rx="12"/>
|
|
||||||
<text class="t" x="436" y="304" font-size="13.5" font-weight="700" fill="#101228">renderowanie stron</text>
|
|
||||||
<text class="t cap" x="436" y="326">każde wejście czytelnika =</text>
|
|
||||||
<text class="t cap" x="436" y="344">praca aplikacji i bazy</text>
|
|
||||||
<rect class="sub" x="420" y="380" width="280" height="88" rx="12"/>
|
|
||||||
<text class="t" x="436" y="406" font-size="13.5" font-weight="700" fill="#101228">pluginy / rozszerzenia</text>
|
|
||||||
<text class="t cap" x="436" y="428">własny cykl aktualizacji</text>
|
|
||||||
<text class="t cap" x="436" y="446">i podatności</text>
|
|
||||||
|
|
||||||
<!-- database -->
|
|
||||||
<g filter="url(#softB)">
|
|
||||||
<rect class="box" x="790" y="120" width="340" height="185" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<text class="t ttl" x="814" y="152">BAZA DANYCH</text>
|
|
||||||
<rect class="chipbg" x="972" y="136" width="106" height="22" rx="11"/>
|
|
||||||
<text class="t chip" x="1025" y="151" text-anchor="middle">NON STOP</text>
|
|
||||||
<text class="t cap" x="814" y="188">treść + konta użytkowników</text>
|
|
||||||
<text class="t cap" x="814" y="212">wymaga backupów i testów odtwarzania</text>
|
|
||||||
<text class="t cap" x="814" y="236">migracje schematu przy aktualizacjach</text>
|
|
||||||
<text class="t cap" x="814" y="266">historia zmian: zależnie od produktu,</text>
|
|
||||||
<text class="t cap" x="814" y="284">bywa funkcją płatną</text>
|
|
||||||
|
|
||||||
<!-- media volume -->
|
|
||||||
<g filter="url(#softB)">
|
|
||||||
<rect class="sub" x="790" y="400" width="340" height="120" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<text class="t ttl" x="814" y="432">media/</text>
|
|
||||||
<rect class="chipbg" x="896" y="416" width="82" height="22" rx="11"/>
|
|
||||||
<text class="t chip" x="937" y="431" text-anchor="middle">WOLUMEN</text>
|
|
||||||
<text class="t cap" x="814" y="462">obrazy i pliki poza bazą</text>
|
|
||||||
<text class="t cap" x="814" y="480">osobny backup</text>
|
|
||||||
|
|
||||||
<!-- flows -->
|
|
||||||
<path class="flow" d="M272 180 H396"/>
|
|
||||||
<text class="t lbl" x="334" y="170" text-anchor="middle">logowanie do panelu</text>
|
|
||||||
|
|
||||||
<path class="flow" d="M400 460 H276"/>
|
|
||||||
<text class="t lbl" x="338" y="450" text-anchor="middle">HTML na żądanie</text>
|
|
||||||
|
|
||||||
<path class="flow" d="M720 212 H786"/>
|
|
||||||
<text class="t lbl" x="753" y="202" text-anchor="middle">SQL</text>
|
|
||||||
|
|
||||||
<path class="flow" d="M720 440 H786"/>
|
|
||||||
<text class="t lbl" x="753" y="430" text-anchor="middle">upload</text>
|
|
||||||
|
|
||||||
<!-- summary pills -->
|
|
||||||
<rect class="pill" x="380" y="552" width="360" height="36" rx="18"/>
|
|
||||||
<text class="t pilltxt" x="560" y="575" text-anchor="middle">2 procesy działają bez przerwy, także nocą</text>
|
|
||||||
<rect class="pill" x="770" y="552" width="360" height="36" rx="18"/>
|
|
||||||
<text class="t pilltxt" x="950" y="575" text-anchor="middle">aktualizacje bezpieczeństwa = stały obowiązek</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -1,111 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 640" role="img" aria-label="Architektura demo na maszynie: redaktor i czytelnik łączą się z kontenerami podmana — Gitea, build Astro, wolumen releases i nginx">
|
|
||||||
<defs>
|
|
||||||
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
||||||
<path d="M0 0 L10 5 L0 10 z" fill="#00004a"/>
|
|
||||||
</marker>
|
|
||||||
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="5" flood-color="#00004a" flood-opacity="0.10"/>
|
|
||||||
</filter>
|
|
||||||
<style>
|
|
||||||
.t { font-family: Inter, "Segoe UI", Arial, sans-serif; }
|
|
||||||
.box { fill: #ffffff; stroke: #00004a; stroke-width: 1.5; }
|
|
||||||
.sub { fill: #f5f5f5; stroke: #e2e5ee; stroke-width: 1; }
|
|
||||||
.ttl { font-weight: 700; font-size: 15.5px; fill: #00004a; letter-spacing: .04em; }
|
|
||||||
.cap { font-size: 12.5px; fill: #46536a; }
|
|
||||||
.lbl { font-size: 12px; fill: #46536a; paint-order: stroke; stroke: #ffffff; stroke-width: 4; }
|
|
||||||
.chipbg { fill: #d2faf7; }
|
|
||||||
.chip { font-size: 11.5px; font-weight: 700; fill: #00004a; letter-spacing: .06em; }
|
|
||||||
.flow { stroke: #00004a; stroke-width: 1.8; fill: none; marker-end: url(#arr); }
|
|
||||||
.mono { font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 12.5px; fill: #101228; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- machine boundary -->
|
|
||||||
<rect x="340" y="40" width="830" height="570" rx="18" fill="#f8f9fc" stroke="#e2e5ee" stroke-width="1.5"/>
|
|
||||||
<text class="t" x="366" y="76" font-size="13" font-weight="700" fill="#00004a" letter-spacing=".18em">MASZYNA DEMO · PODMAN</text>
|
|
||||||
<text class="t cap" x="366" y="96">osobne kontenery pod systemd · wszystko w jednym katalogu użytkownika</text>
|
|
||||||
|
|
||||||
<!-- actor: redaktor -->
|
|
||||||
<g filter="url(#soft)">
|
|
||||||
<rect class="box" x="52" y="120" width="220" height="120" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<circle cx="88" cy="156" r="11" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<path d="M72 186 q16 -18 32 0" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<text class="t ttl" x="116" y="162">Redaktor</text>
|
|
||||||
<text class="t cap" x="70" y="212">przeglądarka · <tspan font-weight="600">/admin/</tspan> (Sveltia)</text>
|
|
||||||
<text class="t cap" x="70" y="228">edycja WYSIWYG, podgląd 1:1</text>
|
|
||||||
|
|
||||||
<!-- actor: czytelnik -->
|
|
||||||
<g filter="url(#soft)">
|
|
||||||
<rect class="box" x="52" y="400" width="220" height="120" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<circle cx="88" cy="436" r="11" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<path d="M72 466 q16 -18 32 0" fill="none" stroke="#00004a" stroke-width="1.8"/>
|
|
||||||
<text class="t ttl" x="116" y="442">Czytelnik</text>
|
|
||||||
<text class="t cap" x="70" y="492">publiczna strona portalu</text>
|
|
||||||
<text class="t cap" x="70" y="508">czysty, szybki HTML</text>
|
|
||||||
|
|
||||||
<!-- gitea -->
|
|
||||||
<g filter="url(#soft)">
|
|
||||||
<rect class="box" x="400" y="120" width="320" height="185" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<text class="t ttl" x="424" y="152">GITEA</text>
|
|
||||||
<rect class="chipbg" x="490" y="136" width="52" height="22" rx="11"/>
|
|
||||||
<text class="t chip" x="516" y="151" text-anchor="middle">:3000</text>
|
|
||||||
<rect class="sub" x="420" y="172" width="280" height="112" rx="12"/>
|
|
||||||
<text class="t" x="436" y="198" font-size="13.5" font-weight="700" fill="#101228">repo ctao/portal</text>
|
|
||||||
<text class="t cap" x="436" y="220">treść: Markdown + obrazy (WebP)</text>
|
|
||||||
<text class="t cap" x="436" y="238">każda zmiana = commit</text>
|
|
||||||
<text class="t cap" x="436" y="256">pełna historia + rollback za darmo</text>
|
|
||||||
|
|
||||||
<!-- build (ephemeral) -->
|
|
||||||
<g filter="url(#soft)">
|
|
||||||
<rect x="790" y="120" width="340" height="185" rx="16" fill="#ffffff" stroke="#00004a" stroke-width="1.5" stroke-dasharray="7 5"/>
|
|
||||||
</g>
|
|
||||||
<text class="t ttl" x="814" y="152">BUILD · ASTRO</text>
|
|
||||||
<rect class="chipbg" x="948" y="136" width="164" height="22" rx="11"/>
|
|
||||||
<text class="t chip" x="1030" y="151" text-anchor="middle">KONTENER JEDNORAZOWY</text>
|
|
||||||
<text class="t cap" x="814" y="188">timer systemd (co ~10 s) sprawdza,</text>
|
|
||||||
<text class="t cap" x="814" y="206">czy w repo jest nowy commit</text>
|
|
||||||
<text class="t cap" x="814" y="234">jeśli tak: git fetch → checkout →</text>
|
|
||||||
<text class="t cap" x="814" y="252"><tspan font-weight="700" fill="#101228">Markdown → statyczny HTML</tspan></text>
|
|
||||||
<text class="t cap" x="814" y="280">po buildzie kontener znika</text>
|
|
||||||
|
|
||||||
<!-- releases volume -->
|
|
||||||
<g filter="url(#soft)">
|
|
||||||
<rect class="sub" x="790" y="400" width="340" height="150" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<text class="t ttl" x="814" y="432">releases/</text>
|
|
||||||
<rect class="chipbg" x="908" y="416" width="82" height="22" rx="11"/>
|
|
||||||
<text class="t chip" x="949" y="431" text-anchor="middle">WOLUMEN</text>
|
|
||||||
<text class="t mono" x="814" y="466">9f2c1d/ poprzednia wersja</text>
|
|
||||||
<text class="t mono" x="814" y="488">71b980/ <tspan font-weight="700" fill="#00004a">← current (symlink)</tspan></text>
|
|
||||||
<text class="t cap" x="814" y="518">podmiana atomowa — zero przestoju</text>
|
|
||||||
|
|
||||||
<!-- nginx -->
|
|
||||||
<g filter="url(#soft)">
|
|
||||||
<rect class="box" x="400" y="400" width="320" height="150" rx="16"/>
|
|
||||||
</g>
|
|
||||||
<text class="t ttl" x="424" y="432">NGINX</text>
|
|
||||||
<rect class="chipbg" x="500" y="416" width="52" height="22" rx="11"/>
|
|
||||||
<text class="t chip" x="526" y="431" text-anchor="middle">:8080</text>
|
|
||||||
<text class="t cap" x="424" y="466">serwuje <tspan font-weight="600">releases/current</tspan></text>
|
|
||||||
<text class="t cap" x="424" y="484">wyłącznie statyczne pliki</text>
|
|
||||||
<text class="t cap" x="424" y="502">brak aplikacji · brak bazy · brak PHP</text>
|
|
||||||
|
|
||||||
<!-- flows -->
|
|
||||||
<path class="flow" d="M272 180 H396"/>
|
|
||||||
<text class="t lbl" x="334" y="170" text-anchor="middle">zapis przez API (OAuth)</text>
|
|
||||||
|
|
||||||
<path class="flow" d="M790 212 H724"/>
|
|
||||||
<text class="t lbl" x="757" y="202" text-anchor="middle">git fetch</text>
|
|
||||||
|
|
||||||
<path class="flow" d="M960 305 V396"/>
|
|
||||||
<text class="t lbl" x="968" y="354" text-anchor="start">nowa wersja strony</text>
|
|
||||||
|
|
||||||
<path class="flow" d="M790 475 H724"/>
|
|
||||||
<text class="t lbl" x="757" y="465" text-anchor="middle">current/</text>
|
|
||||||
|
|
||||||
<path class="flow" d="M400 475 H276"/>
|
|
||||||
<text class="t lbl" x="338" y="465" text-anchor="middle">statyczny HTML</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 6.1 KiB |
@@ -1,78 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-label="Grafika: graf commitów w gicie przechodzący w statyczną stronę WWW">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#00004a"/>
|
|
||||||
<stop offset=".55" stop-color="#000063"/>
|
|
||||||
<stop offset="1" stop-color="#00009c"/>
|
|
||||||
</linearGradient>
|
|
||||||
<radialGradient id="glow" cx=".5" cy=".5" r=".5">
|
|
||||||
<stop offset="0" stop-color="#00e4d8" stop-opacity=".55"/>
|
|
||||||
<stop offset="1" stop-color="#00e4d8" stop-opacity="0"/>
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient id="page" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stop-color="#ffffff"/>
|
|
||||||
<stop offset="1" stop-color="#eef1f8"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="blur18"><feGaussianBlur stdDeviation="18"/></filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="1600" height="900" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<!-- starfield -->
|
|
||||||
<g fill="#ffffff">
|
|
||||||
<circle cx="140" cy="130" r="2" opacity=".55"/><circle cx="360" cy="80" r="1.4" opacity=".35"/>
|
|
||||||
<circle cx="620" cy="150" r="1.8" opacity=".45"/><circle cx="905" cy="70" r="1.4" opacity=".4"/>
|
|
||||||
<circle cx="1210" cy="120" r="2" opacity=".5"/><circle cx="1430" cy="200" r="1.5" opacity=".35"/>
|
|
||||||
<circle cx="90" cy="420" r="1.5" opacity=".35"/><circle cx="240" cy="700" r="2" opacity=".45"/>
|
|
||||||
<circle cx="520" cy="810" r="1.4" opacity=".3"/><circle cx="1050" cy="840" r="1.8" opacity=".4"/>
|
|
||||||
<circle cx="1330" cy="760" r="1.4" opacity=".35"/><circle cx="1500" cy="560" r="2" opacity=".5"/>
|
|
||||||
<circle cx="760" cy="330" r="1.3" opacity=".3"/><circle cx="1130" cy="300" r="1.5" opacity=".35"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- cherenkov glow behind the merge point -->
|
|
||||||
<circle cx="560" cy="450" r="260" fill="url(#glow)" filter="url(#blur18)"/>
|
|
||||||
|
|
||||||
<!-- git graph: two branches merging -->
|
|
||||||
<g fill="none" stroke-linecap="round">
|
|
||||||
<path d="M220 260 H420 Q500 260 540 350 L560 450" stroke="#3d3dc4" stroke-width="10"/>
|
|
||||||
<path d="M220 640 H420 Q500 640 540 550 L560 450" stroke="#3d3dc4" stroke-width="10"/>
|
|
||||||
<path d="M560 450 H840" stroke="#00e4d8" stroke-width="12"/>
|
|
||||||
</g>
|
|
||||||
<!-- commit nodes -->
|
|
||||||
<g stroke="#00004a" stroke-width="6">
|
|
||||||
<circle cx="260" cy="260" r="22" fill="#ffffff"/>
|
|
||||||
<circle cx="400" cy="260" r="22" fill="#ffffff"/>
|
|
||||||
<circle cx="260" cy="640" r="22" fill="#ffffff"/>
|
|
||||||
<circle cx="400" cy="640" r="22" fill="#ffffff"/>
|
|
||||||
<circle cx="560" cy="450" r="30" fill="#00e4d8"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- flowing into a static page -->
|
|
||||||
<g>
|
|
||||||
<!-- back pages -->
|
|
||||||
<rect x="920" y="212" width="420" height="520" rx="24" fill="#c7cde9" opacity=".45" transform="rotate(-4 1130 470)"/>
|
|
||||||
<rect x="905" y="200" width="420" height="520" rx="24" fill="#c7cde9" opacity=".7" transform="rotate(-2 1115 460)"/>
|
|
||||||
<!-- front page -->
|
|
||||||
<rect x="890" y="190" width="430" height="530" rx="24" fill="url(#page)"/>
|
|
||||||
<!-- browser dots -->
|
|
||||||
<circle cx="930" cy="232" r="8" fill="#e2e5ee"/><circle cx="958" cy="232" r="8" fill="#e2e5ee"/><circle cx="986" cy="232" r="8" fill="#e2e5ee"/>
|
|
||||||
<!-- headline + text lines -->
|
|
||||||
<rect x="930" y="286" width="270" height="26" rx="13" fill="#00004a"/>
|
|
||||||
<rect x="930" y="336" width="350" height="14" rx="7" fill="#9aa3bf"/>
|
|
||||||
<rect x="930" y="366" width="330" height="14" rx="7" fill="#9aa3bf"/>
|
|
||||||
<rect x="930" y="396" width="300" height="14" rx="7" fill="#9aa3bf"/>
|
|
||||||
<!-- image placeholder with cherenkov accent -->
|
|
||||||
<rect x="930" y="440" width="350" height="160" rx="16" fill="#e8ecf6"/>
|
|
||||||
<path d="M950 585 L1030 505 L1090 555 L1140 515 L1260 585 Z" fill="#c7cde9"/>
|
|
||||||
<circle cx="1215" cy="480" r="16" fill="#00e4d8"/>
|
|
||||||
<!-- more text -->
|
|
||||||
<rect x="930" y="630" width="340" height="14" rx="7" fill="#9aa3bf"/>
|
|
||||||
<rect x="930" y="660" width="250" height="14" rx="7" fill="#9aa3bf"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- markdown glyph riding the flow -->
|
|
||||||
<g transform="translate(660 380)">
|
|
||||||
<rect x="0" y="0" width="120" height="84" rx="14" fill="#00004a" stroke="#00e4d8" stroke-width="3"/>
|
|
||||||
<text x="60" y="56" text-anchor="middle" font-family="Inter, 'Segoe UI', Arial, sans-serif" font-size="38" font-weight="800" fill="#ffffff">M↓</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,76 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 430" role="img" aria-label="Pięć kroków od zapisu w edytorze do publikacji: zapis, commit, detekcja, build, publikacja">
|
|
||||||
<defs>
|
|
||||||
<marker id="arr2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
||||||
<path d="M0 0 L10 5 L0 10 z" fill="#00004a"/>
|
|
||||||
</marker>
|
|
||||||
<filter id="soft2" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="5" flood-color="#00004a" flood-opacity="0.10"/>
|
|
||||||
</filter>
|
|
||||||
<style>
|
|
||||||
.t { font-family: Inter, "Segoe UI", Arial, sans-serif; }
|
|
||||||
.card { fill: #ffffff; stroke: #00004a; stroke-width: 1.5; }
|
|
||||||
.ttl { font-weight: 700; font-size: 15px; fill: #00004a; }
|
|
||||||
.cap { font-size: 12.3px; fill: #46536a; }
|
|
||||||
.num { font-weight: 800; font-size: 15px; fill: #00004a; }
|
|
||||||
.pill { fill: #f5f5f5; stroke: #e2e5ee; }
|
|
||||||
.pilltxt { font-size: 12.5px; fill: #101228; }
|
|
||||||
.flow { stroke: #00004a; stroke-width: 1.8; fill: none; marker-end: url(#arr2); }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- cards -->
|
|
||||||
<g filter="url(#soft2)"><rect class="card" x="55" y="100" width="200" height="175" rx="16"/></g>
|
|
||||||
<g filter="url(#soft2)"><rect class="card" x="285" y="100" width="200" height="175" rx="16"/></g>
|
|
||||||
<g filter="url(#soft2)"><rect class="card" x="515" y="100" width="200" height="175" rx="16"/></g>
|
|
||||||
<g filter="url(#soft2)"><rect class="card" x="745" y="100" width="200" height="175" rx="16"/></g>
|
|
||||||
<g filter="url(#soft2)"><rect class="card" x="975" y="100" width="200" height="175" rx="16"/></g>
|
|
||||||
|
|
||||||
<!-- connectors -->
|
|
||||||
<path class="flow" d="M255 187 H281"/>
|
|
||||||
<path class="flow" d="M485 187 H511"/>
|
|
||||||
<path class="flow" d="M715 187 H741"/>
|
|
||||||
<path class="flow" d="M945 187 H971"/>
|
|
||||||
|
|
||||||
<!-- step numbers -->
|
|
||||||
<circle cx="155" cy="100" r="17" fill="#00e4d8"/><text class="t num" x="155" y="106" text-anchor="middle">1</text>
|
|
||||||
<circle cx="385" cy="100" r="17" fill="#00e4d8"/><text class="t num" x="385" y="106" text-anchor="middle">2</text>
|
|
||||||
<circle cx="615" cy="100" r="17" fill="#00e4d8"/><text class="t num" x="615" y="106" text-anchor="middle">3</text>
|
|
||||||
<circle cx="845" cy="100" r="17" fill="#00e4d8"/><text class="t num" x="845" y="106" text-anchor="middle">4</text>
|
|
||||||
<circle cx="1075" cy="100" r="17" fill="#00e4d8"/><text class="t num" x="1075" y="106" text-anchor="middle">5</text>
|
|
||||||
|
|
||||||
<!-- card 1 -->
|
|
||||||
<text class="t ttl" x="155" y="152" text-anchor="middle">Zapis w edytorze</text>
|
|
||||||
<text class="t cap" x="155" y="182" text-anchor="middle">Sveltia · WYSIWYG</text>
|
|
||||||
<text class="t cap" x="155" y="200" text-anchor="middle">podgląd artykułu 1:1</text>
|
|
||||||
<text class="t cap" x="155" y="218" text-anchor="middle">obrazy → WebP przy uploadzie</text>
|
|
||||||
|
|
||||||
<!-- card 2 -->
|
|
||||||
<text class="t ttl" x="385" y="152" text-anchor="middle">Commit w repo</text>
|
|
||||||
<text class="t cap" x="385" y="182" text-anchor="middle">Gitea · logowanie OAuth</text>
|
|
||||||
<text class="t cap" x="385" y="200" text-anchor="middle">zapis przez API = commit</text>
|
|
||||||
<text class="t cap" x="385" y="218" text-anchor="middle">historia każdej zmiany</text>
|
|
||||||
|
|
||||||
<!-- card 3 -->
|
|
||||||
<text class="t ttl" x="615" y="152" text-anchor="middle">Detekcja zmiany</text>
|
|
||||||
<text class="t cap" x="615" y="182" text-anchor="middle">timer systemd</text>
|
|
||||||
<text class="t cap" x="615" y="200" text-anchor="middle">sprawdza repo co ~10 s</text>
|
|
||||||
<text class="t cap" x="615" y="218" text-anchor="middle">brak zmian = brak pracy</text>
|
|
||||||
|
|
||||||
<!-- card 4 -->
|
|
||||||
<text class="t ttl" x="845" y="152" text-anchor="middle">Build strony</text>
|
|
||||||
<text class="t cap" x="845" y="182" text-anchor="middle">jednorazowy kontener</text>
|
|
||||||
<text class="t cap" x="845" y="200" text-anchor="middle">Astro: Markdown →</text>
|
|
||||||
<text class="t cap" x="845" y="218" text-anchor="middle">statyczny HTML</text>
|
|
||||||
|
|
||||||
<!-- card 5 -->
|
|
||||||
<text class="t ttl" x="1075" y="152" text-anchor="middle">Publikacja</text>
|
|
||||||
<text class="t cap" x="1075" y="182" text-anchor="middle">atomowa podmiana symlinka</text>
|
|
||||||
<text class="t cap" x="1075" y="200" text-anchor="middle">nginx od razu serwuje</text>
|
|
||||||
<text class="t cap" x="1075" y="218" text-anchor="middle">nową wersję</text>
|
|
||||||
|
|
||||||
<!-- reassurance pills -->
|
|
||||||
<rect class="pill" x="240" y="330" width="330" height="40" rx="20"/>
|
|
||||||
<text class="t pilltxt" x="405" y="355" text-anchor="middle">awaria builda? strona działa dalej na starej wersji</text>
|
|
||||||
<rect class="pill" x="630" y="330" width="330" height="40" rx="20"/>
|
|
||||||
<text class="t pilltxt" x="795" y="355" text-anchor="middle">rollback = git revert — jedna komenda</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.5 KiB |
@@ -1,48 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" role="img" aria-label="Porównanie powierzchni ataku: CMS z bazą wystawia publicznie aplikację, bazę i pluginy; rozwiązanie statyczne wystawia tylko pliki HTML, a edycja idzie przez git">
|
|
||||||
<defs>
|
|
||||||
<filter id="soft3" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="5" flood-color="#00004a" flood-opacity="0.10"/>
|
|
||||||
</filter>
|
|
||||||
<marker id="arr3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
||||||
<path d="M0 0 L10 5 L0 10 z" fill="#00004a"/>
|
|
||||||
</marker>
|
|
||||||
<style>
|
|
||||||
.t { font-family: Inter, "Segoe UI", Arial, sans-serif; }
|
|
||||||
.hd { font-weight: 800; font-size: 14px; fill: #00004a; letter-spacing: .14em; }
|
|
||||||
.cap { font-size: 12.5px; fill: #46536a; }
|
|
||||||
.item{ font-size: 13px; fill: #101228; }
|
|
||||||
.bad { fill: #fdf0f0; stroke: #e8c5c5; stroke-width: 1.5; }
|
|
||||||
.good{ fill: #ffffff; stroke: #00004a; stroke-width: 1.5; }
|
|
||||||
.flow{ stroke: #00004a; stroke-width: 1.8; fill: none; marker-end: url(#arr3); }
|
|
||||||
.lbl { font-size: 12px; fill: #46536a; paint-order: stroke; stroke: #ffffff; stroke-width: 4; }
|
|
||||||
</style>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- left: classic CMS -->
|
|
||||||
<text class="t hd" x="300" y="52" text-anchor="middle">CMS Z BAZĄ DANYCH</text>
|
|
||||||
<text class="t item" x="60" y="150">internet</text>
|
|
||||||
<path class="flow" d="M130 145 H186"/>
|
|
||||||
<g filter="url(#soft3)"><rect class="bad" x="190" y="80" width="360" height="200" rx="16"/></g>
|
|
||||||
<text class="t item" x="218" y="126">▪ aplikacja CMS (PHP / Node) — publiczna</text>
|
|
||||||
<text class="t item" x="218" y="158">▪ panel logowania — publiczny</text>
|
|
||||||
<text class="t item" x="218" y="190">▪ baza danych z treścią</text>
|
|
||||||
<text class="t item" x="218" y="222">▪ pluginy i zależności</text>
|
|
||||||
<text class="t cap" x="218" y="256" font-weight="700" fill="#8f3b3b">wszystko działa non stop i wymaga patchowania</text>
|
|
||||||
|
|
||||||
<!-- divider -->
|
|
||||||
<line x1="600" y1="60" x2="600" y2="320" stroke="#e2e5ee" stroke-width="1.5"/>
|
|
||||||
|
|
||||||
<!-- right: static -->
|
|
||||||
<text class="t hd" x="900" y="52" text-anchor="middle">TO ROZWIĄZANIE</text>
|
|
||||||
<text class="t item" x="646" y="130">internet</text>
|
|
||||||
<path class="flow" d="M716 125 H772"/>
|
|
||||||
<g filter="url(#soft3)"><rect class="good" x="776" y="88" width="360" height="76" rx="16"/></g>
|
|
||||||
<text class="t item" x="804" y="120">▪ pliki HTML — i nic więcej</text>
|
|
||||||
<text class="t cap" x="804" y="146">nie ma czego zhakować ani patchować</text>
|
|
||||||
|
|
||||||
<text class="t item" x="646" y="252">edycja</text>
|
|
||||||
<path class="flow" d="M716 247 H772"/>
|
|
||||||
<g filter="url(#soft3)"><rect class="good" x="776" y="210" width="360" height="76" rx="16"/></g>
|
|
||||||
<text class="t item" x="804" y="242">▪ git (Gitea) — tylko zalogowani</text>
|
|
||||||
<text class="t cap" x="804" y="268">poza ścieżką czytelnika — awaria nie kładzie strony</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import { defineCollection, z } from 'astro:content';
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { z } from 'astro/zod';
|
||||||
import { glob } from 'astro/loaders';
|
import { glob } from 'astro/loaders';
|
||||||
|
|
||||||
// News/articles live as Markdown files in src/content/news/*.md.
|
// News/articles live as Markdown files in src/content/news/*.md.
|
||||||
@@ -29,7 +30,8 @@ const news = defineCollection({
|
|||||||
// because the content type differs (no dates/covers/categories), and editors
|
// because the content type differs (no dates/covers/categories), and editors
|
||||||
// get a distinct "Pages" section in the CMS.
|
// get a distinct "Pages" section in the CMS.
|
||||||
const pages = defineCollection({
|
const pages = defineCollection({
|
||||||
loader: glob({ pattern: '**/*.md', base: './src/content/pages' }),
|
// Top-level only, same reason as news: the [slug] route is non-rest.
|
||||||
|
loader: glob({ pattern: '*.md', base: './src/content/pages' }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
// `ambient` opts a page into the whole-page drifting brand wash (home only — see DESIGN.md)
|
// `ambient` opts a page into the whole-page drifting brand wash (home only)
|
||||||
// `type`/`image` feed the social meta: articles pass type="article" + their cover.
|
// `type`/`image` feed the social meta: articles pass type="article" + their cover.
|
||||||
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal demo', ambient = false, type = 'website', image } = Astro.props;
|
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal demo', ambient = false, type = 'website', image } = Astro.props;
|
||||||
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro
|
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro
|
||||||
@@ -11,7 +11,7 @@ const canonical = new URL(Astro.url.pathname, site);
|
|||||||
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
|
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
|
||||||
const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site);
|
const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site);
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
|
const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
|
||||||
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
|
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
|
||||||
// Order: serial-position effect (NN/g) — News (universal entry) first, Support
|
// Order: serial-position effect (NN/g) — News (universal entry) first, Support
|
||||||
// (help convention) last; Data/Proposals by task frequency; Dashboard is the
|
// (help convention) last; Data/Proposals by task frequency; Dashboard is the
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
---
|
---
|
||||||
<Base title="CTAO Science Portal - Page not found" description="This page does not exist">
|
<Base title="CTAO Science Portal - Page not found" description="This page does not exist">
|
||||||
{/* Document archetype (DESIGN.md): the document IS the page — h1, one line,
|
{/* Document archetype: the document IS the page — h1, one line,
|
||||||
recovery links, and a real GET search form as the way forward. */}
|
recovery links, and a real GET search form as the way forward. */}
|
||||||
<div class="container article-layout">
|
<div class="container article-layout">
|
||||||
<article class="article">
|
<article class="article">
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import NewsCard from '../components/NewsCard.astro';
|
|||||||
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
const posts = (await getCollection('news', ({ data }) => !data.draft))
|
||||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
// Services per the Science Portal spec. Tiles carry no status chrome — honesty
|
// Services per the Science Portal spec (REQUIREMENTS §5). Tiles carry no
|
||||||
// lives at the interaction point (REQUIREMENTS §5): each target page opens with
|
// status chrome; the footer's demo note is the single mock disclosure.
|
||||||
// its own whisper badge. Planned services stay in the grid but subdued (muted,
|
// Planned services stay in the grid but subdued (muted, no link) with
|
||||||
// no link) with availability folded into the description text.
|
// availability folded into the description text.
|
||||||
// Tile icons: hand-drawn stroke glyphs in the header-icon family (.ico —
|
// Tile icons: hand-drawn stroke glyphs in the header-icon family (.ico —
|
||||||
// currentColor, round caps), aria-hidden. NN/g icon research: icons alone are
|
// currentColor, round caps), aria-hidden. NN/g icon research: icons alone are
|
||||||
// ambiguous, but NEXT TO an always-visible label they differentiate otherwise
|
// ambiguous, but NEXT TO an always-visible label they differentiate otherwise
|
||||||
@@ -35,7 +35,7 @@ const planned = ['Scheduling', 'Science alerts'];
|
|||||||
<Base title="CTAO Science Portal" description="News, observation proposals, data and user services of the Cherenkov Telescope Array Observatory" ambient>
|
<Base title="CTAO Science Portal" description="News, observation proposals, data and user services of the Cherenkov Telescope Array Observatory" ambient>
|
||||||
{/* Photographic hero: LST-1 under the La Palma night sky (copied from the
|
{/* Photographic hero: LST-1 under the La Palma night sky (copied from the
|
||||||
content library to /brand/hero.jpg) beneath the Galaxy scrim — text
|
content library to /brand/hero.jpg) beneath the Galaxy scrim — text
|
||||||
contrast computed worst-case in DESIGN.md. Three elements only:
|
contrast computed worst-case at design time. Three elements only:
|
||||||
headline, brand subtitle, CTA pair (Apple hero restraint). */}
|
headline, brand subtitle, CTA pair (Apple hero restraint). */}
|
||||||
<section class="hero-band">
|
<section class="hero-band">
|
||||||
<div class="band-bg" aria-hidden="true"></div>
|
<div class="band-bg" aria-hidden="true"></div>
|
||||||
@@ -60,7 +60,7 @@ const planned = ['Scheduling', 'Science alerts'];
|
|||||||
for the user's top tasks; for scientists that is proposals/data/support,
|
for the user's top tasks; for scientists that is proposals/data/support,
|
||||||
not reading news. Freshness is still signalled above the fold by the
|
not reading news. Freshness is still signalled above the fold by the
|
||||||
one-line hero teaser, so the 3-card news section can sit below.
|
one-line hero teaser, so the 3-card news section can sit below.
|
||||||
Surface rhythm (DESIGN.md): services on Moon Gray, editorial news on white. */}
|
Surface rhythm: services on Moon Gray, editorial news on white. */}
|
||||||
<section class="band--moon">
|
<section class="band--moon">
|
||||||
<div class="container page">
|
<div class="container page">
|
||||||
<div class="section-head"><h2>Services</h2></div>
|
<div class="section-head"><h2>Services</h2></div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function getStaticPaths() {
|
|||||||
const chunks = [posts.slice(0, FIRST)];
|
const chunks = [posts.slice(0, FIRST)];
|
||||||
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
|
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
|
||||||
const lastPage = chunks.length;
|
const lastPage = chunks.length;
|
||||||
const hrefOf = (n) => (n === 1 ? '/news' : `/news/${n}`);
|
const hrefOf = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
|
||||||
return chunks.map((data, i) => {
|
return chunks.map((data, i) => {
|
||||||
const n = i + 1;
|
const n = i + 1;
|
||||||
return {
|
return {
|
||||||
@@ -42,14 +42,14 @@ for (let n = 1; n <= page.lastPage; n++) {
|
|||||||
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(n);
|
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(n);
|
||||||
else if (nums.at(-1) !== 0) nums.push(0);
|
else if (nums.at(-1) !== 0) nums.push(0);
|
||||||
}
|
}
|
||||||
const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
|
const hrefFor = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
|
||||||
---
|
---
|
||||||
<Base
|
<Base
|
||||||
title={first ? 'CTAO Science Portal - News' : `CTAO Science Portal - News, page ${page.currentPage}`}
|
title={first ? 'CTAO Science Portal - News' : `CTAO Science Portal - News, page ${page.currentPage}`}
|
||||||
description={first ? 'News and announcements of the CTAO' : `News and announcements of the CTAO, page ${page.currentPage} of ${page.lastPage}`}
|
description={first ? 'News and announcements of the CTAO' : `News and announcements of the CTAO, page ${page.currentPage} of ${page.lastPage}`}
|
||||||
>
|
>
|
||||||
{/* Section-landing archetype: white head, display Galaxy h1 + standfirst
|
{/* Section-landing archetype: white head, display Galaxy h1 + standfirst
|
||||||
(the navy band is reserved for / and /login — DESIGN.md). */}
|
(the navy band is reserved for / and /login). */}
|
||||||
<header class="container page-head">
|
<header class="container page-head">
|
||||||
<h1>News & Announcements</h1>
|
<h1>News & Announcements</h1>
|
||||||
<p class="standfirst">Science highlights and updates from the observatory and its partners.</p>
|
<p class="standfirst">Science highlights and updates from the observatory and its partners.</p>
|
||||||
|
|||||||
+12
-11
@@ -25,7 +25,7 @@ const readMin = readMinOf(post);
|
|||||||
animation-timeline is supported; elsewhere it stays an empty div. */}
|
animation-timeline is supported; elsewhere it stays an empty div. */}
|
||||||
<div class="read-progress" aria-hidden="true"></div>
|
<div class="read-progress" aria-hidden="true"></div>
|
||||||
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
|
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
|
||||||
under the title below that. CSS shows exactly one of the two (DESIGN.md). */}
|
under the title below that. CSS shows exactly one of the two. */}
|
||||||
{/* lang sits on the article, not <html>: the chrome (nav/footer) stays
|
{/* lang sits on the article, not <html>: the chrome (nav/footer) stays
|
||||||
English for screen readers even when the article body is not. */}
|
English for screen readers even when the article body is not. */}
|
||||||
<div class="container article-layout">
|
<div class="container article-layout">
|
||||||
@@ -96,10 +96,10 @@ const readMin = readMinOf(post);
|
|||||||
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get
|
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get
|
||||||
// aria-current="true" (set in BOTH renderings — inline <details> and rail).
|
// aria-current="true" (set in BOTH renderings — inline <details> and rail).
|
||||||
// Progressive enhancement: without JS the TOC is plain working anchors.
|
// Progressive enhancement: without JS the TOC is plain working anchors.
|
||||||
const links = [...document.querySelectorAll('.toc a[href^="#"]')];
|
const links = [...document.querySelectorAll<HTMLAnchorElement>('.toc a[href^="#"]')];
|
||||||
if (links.length) {
|
if (links.length) {
|
||||||
let current = [];
|
let current: HTMLAnchorElement[] = [];
|
||||||
const setCurrent = (id) => {
|
const setCurrent = (id: string | null) => {
|
||||||
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
|
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
|
||||||
if (next[0] === current[0]) return;
|
if (next[0] === current[0]) return;
|
||||||
for (const a of current) a.removeAttribute('aria-current');
|
for (const a of current) a.removeAttribute('aria-current');
|
||||||
@@ -108,35 +108,36 @@ const readMin = readMinOf(post);
|
|||||||
};
|
};
|
||||||
const blocks = [...document.querySelectorAll('.prose > *')];
|
const blocks = [...document.querySelectorAll('.prose > *')];
|
||||||
// Governing heading = the block itself or the nearest h2/h3[id] above it
|
// Governing heading = the block itself or the nearest h2/h3[id] above it
|
||||||
const headingFor = (el) => {
|
const headingFor = (el: Element) => {
|
||||||
for (let i = blocks.indexOf(el); i >= 0; i--) {
|
for (let i = blocks.indexOf(el); i >= 0; i--) {
|
||||||
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
|
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
|
||||||
}
|
}
|
||||||
return null; // intro before the first heading — nothing highlighted
|
return null; // intro before the first heading — nothing highlighted
|
||||||
};
|
};
|
||||||
const onIntersect = (entries) => {
|
const onIntersect = (entries: IntersectionObserverEntry[]) => {
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
|
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let observer;
|
let observer: IntersectionObserver | undefined;
|
||||||
const observe = () => {
|
const observe = () => {
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
// Narrow band below the sticky header (--header-h 56px + 12px slack;
|
// Narrow band below the sticky header (--header-h 56px + 12px slack;
|
||||||
// JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
|
// JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
|
||||||
const top = 68, band = 64;
|
const top = 68, band = 64;
|
||||||
observer = new IntersectionObserver(onIntersect, {
|
const io = new IntersectionObserver(onIntersect, {
|
||||||
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
|
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
|
||||||
});
|
});
|
||||||
blocks.forEach((b) => observer.observe(b));
|
observer = io;
|
||||||
|
blocks.forEach((b) => io.observe(b));
|
||||||
};
|
};
|
||||||
observe();
|
observe();
|
||||||
let timer;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
|
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
|
||||||
// Short final section: at page bottom, the last heading wins
|
// Short final section: at page bottom, the last heading wins
|
||||||
addEventListener('scroll', () => {
|
addEventListener('scroll', () => {
|
||||||
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
|
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
|
||||||
setCurrent(headingFor(blocks.at(-1)));
|
setCurrent(headingFor(blocks[blocks.length - 1]));
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Base from '../layouts/Base.astro';
|
|||||||
<Base title="CTAO Science Portal - Search" description="Search CTAO news and announcements">
|
<Base title="CTAO Science Portal - Search" description="Search CTAO news and announcements">
|
||||||
{/* Utility archetype: ONE surface per page — Moon canvas from the landing
|
{/* Utility archetype: ONE surface per page — Moon canvas from the landing
|
||||||
head down (borderless cards/panels pop against it); editorial pages stay
|
head down (borderless cards/panels pop against it); editorial pages stay
|
||||||
all-white. No mid-page surface seams (DESIGN.md). */}
|
all-white. No mid-page surface seams. */}
|
||||||
<section class="band--moon">
|
<section class="band--moon">
|
||||||
<header class="container page-head">
|
<header class="container page-head">
|
||||||
<h1>Search</h1>
|
<h1>Search</h1>
|
||||||
|
|||||||
+9
-41
@@ -2,9 +2,9 @@
|
|||||||
CTAO Science Portal — design tokens per the official CTAO Brand Guidelines
|
CTAO Science Portal — design tokens per the official CTAO Brand Guidelines
|
||||||
(April 2024): colors D.2, typography D.3 (Inter body / Space Grotesk display
|
(April 2024): colors D.2, typography D.3 (Inter body / Space Grotesk display
|
||||||
at medium weight per D.3.4), flash D.4. All look-and-feel lives in the tokens
|
at medium weight per D.3.4), flash D.4. All look-and-feel lives in the tokens
|
||||||
below; WCAG 2.1 AA contrast (photo-hero worst cases computed — see DESIGN.md).
|
below; WCAG 2.1 AA contrast (photo-hero worst cases computed at design time; recompute if scrim or glow alphas change).
|
||||||
Light theme only (brand is light-first; navy bands carry the dark identity).
|
Light theme only (brand is light-first; navy bands carry the dark identity).
|
||||||
System rules (spacing / elevation / surfaces / motion): see DESIGN.md.
|
System rules (spacing / elevation / surfaces / motion) live in the comments next to each block below.
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
--cherenkov: #00e4d8; /* Cherenkov Blue — primary CTA, flash, hero subtitle, logo cluster ONLY */
|
--cherenkov: #00e4d8; /* Cherenkov Blue — primary CTA, flash, hero subtitle, logo cluster ONLY */
|
||||||
--moon: #f5f5f5; /* Moon Gray — surfaces */
|
--moon: #f5f5f5; /* Moon Gray — surfaces */
|
||||||
--azure: #007aff; /* Cosmic Azure — focus rings (UI only on light) */
|
--azure: #007aff; /* Cosmic Azure — focus rings (UI only on light) */
|
||||||
--indigo: #00009c; /* Interestellar Indigo — gradient depth */
|
--indigo: #00009c; /* Interstellar Indigo — gradient depth */
|
||||||
/* Derived, WCAG-adjusted */
|
/* Derived, WCAG-adjusted */
|
||||||
--text: #101228; /* near-Galaxy body ink */
|
--text: #101228; /* near-Galaxy body ink */
|
||||||
--muted: #46536a;
|
--muted: #46536a;
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
--dur: 0.25s;
|
--dur: 0.25s;
|
||||||
--ease: ease-out;
|
--ease: ease-out;
|
||||||
/* Nebula glow (BRAND D.5) — auth band, static AND animated. Alphas are
|
/* Nebula glow (BRAND D.5) — auth band, static AND animated. Alphas are
|
||||||
contrast ceilings (DESIGN.md): Cherenkov ≤.38 / Azure ≤.35; cores sit in
|
contrast ceilings (computed for AA at design time): Cherenkov ≤.38 / Azure ≤.35; cores sit in
|
||||||
opposite corners — cores never meet. */
|
opposite corners — cores never meet. */
|
||||||
/* Aurora rule (Gemini-style): gradient-only surfaces (login) get LARGE, soft,
|
/* Aurora rule (Gemini-style): gradient-only surfaces (login) get LARGE, soft,
|
||||||
slowly flowing color fields — one protagonist per surface: the photo hero
|
slowly flowing color fields — one protagonist per surface: the photo hero
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
radial-gradient(60% 70% at 78% 78%, rgba(0, 228, 216, 0.42), transparent 70%),
|
radial-gradient(60% 70% at 78% 78%, rgba(0, 228, 216, 0.42), transparent 70%),
|
||||||
radial-gradient(55% 62% at 10% 12%, rgba(0, 122, 255, 0.36), transparent 68%),
|
radial-gradient(55% 62% at 10% 12%, rgba(0, 122, 255, 0.36), transparent 68%),
|
||||||
radial-gradient(80% 90% at 42% 55%, rgba(0, 0, 156, 0.8), transparent 74%);
|
radial-gradient(80% 90% at 42% 55%, rgba(0, 0, 156, 0.8), transparent 74%);
|
||||||
/* Hero scrim over the photo — worst-case AA computed in DESIGN.md:
|
/* Hero scrim over the photo — worst-case AA computed at design time:
|
||||||
small text lives left of 52% band width (α ≥ .76 there). */
|
small text lives left of 52% band width (α ≥ .76 there). */
|
||||||
--hero-scrim: linear-gradient(to right,
|
--hero-scrim: linear-gradient(to right,
|
||||||
rgba(0, 0, 74, 0.84), rgba(0, 0, 74, 0.76) 52%,
|
rgba(0, 0, 74, 0.84), rgba(0, 0, 74, 0.76) 52%,
|
||||||
@@ -123,7 +123,7 @@ html { -webkit-text-size-adjust: 100%; }
|
|||||||
to the in-content anchor targets fixes typing and keeps every jump
|
to the in-content anchor targets fixes typing and keeps every jump
|
||||||
(TOC headings, skip link → #main) landing below the header. */
|
(TOC headings, skip link → #main) landing below the header. */
|
||||||
main, main [id] { scroll-margin-top: calc(var(--header-h) + 20px); }
|
main, main [id] { scroll-margin-top: calc(var(--header-h) + 20px); }
|
||||||
/* Guard, not a solution (DESIGN.md): every wide element is contained at its own
|
/* Guard, not a solution: every wide element is contained at its own
|
||||||
level (tables scroll in their box, long words wrap); clip only catches
|
level (tables scroll in their box, long words wrap); clip only catches
|
||||||
regressions so one offender can never remove the page gutters. `clip`, unlike
|
regressions so one offender can never remove the page gutters. `clip`, unlike
|
||||||
`hidden`, creates no scroll container — sticky header keeps working. */
|
`hidden`, creates no scroll container — sticky header keeps working. */
|
||||||
@@ -334,7 +334,7 @@ img { max-width: 100%; height: auto; display: block; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hero (home only) — image-led: LST-1 under the night sky, full-bleed beneath a
|
/* Hero (home only) — image-led: LST-1 under the night sky, full-bleed beneath a
|
||||||
Galaxy scrim (--hero-scrim; worst-case AA computed in DESIGN.md). With motion
|
Galaxy scrim (--hero-scrim; worst-case AA computed at design time). With motion
|
||||||
allowed a Cherenkov/Azure glow layer drifts above the photo (see motion block). */
|
allowed a Cherenkov/Azure glow layer drifts above the photo (see motion block). */
|
||||||
.hero-band {
|
.hero-band {
|
||||||
background: var(--hero-scrim), url("/brand/hero.jpg") center / cover, var(--galaxy);
|
background: var(--hero-scrim), url("/brand/hero.jpg") center / cover, var(--galaxy);
|
||||||
@@ -692,38 +692,10 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
|
|||||||
functional state, brand tint, no new hues */
|
functional state, brand tint, no new hues */
|
||||||
mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(var(--radius) - 12px); padding: 0 2px; }
|
mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(var(--radius) - 12px); padding: 0 2px; }
|
||||||
|
|
||||||
/* Mock/status badges — whisper-quiet hairline pill, ONLY where a mock
|
/* Dashboard — aggregation panels (empty states until integrations exist) */
|
||||||
interaction actually happens (/proposals, /dashboard, /login, /support head);
|
|
||||||
REQUIREMENTS §5 honesty lives at the interaction point, not on every tile. */
|
|
||||||
.badge-mock, .badge-ext {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px; max-width: 100%;
|
|
||||||
font-size: var(--fs-xs); font-weight: 500; color: var(--muted);
|
|
||||||
background: var(--moon); border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-pill); padding: 2px 10px; margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.badge-mock::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--galaxy); flex: none; }
|
|
||||||
|
|
||||||
/* Dashboard mock — aggregation panels with status pills */
|
|
||||||
.dash-grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; margin-top: var(--space-s); }
|
.dash-grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; margin-top: var(--space-s); }
|
||||||
@media (min-width: 940px) { .dash-grid { grid-template-columns: repeat(2, 1fr); } }
|
@media (min-width: 940px) { .dash-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
.panel-title { color: var(--galaxy); font-size: var(--fs-l); margin: 0 0 var(--space-s); font-family: var(--font-body); font-weight: 600; }
|
.panel-title { color: var(--galaxy); font-size: var(--fs-l); margin: 0 0 var(--space-s); font-family: var(--font-body); font-weight: 600; }
|
||||||
.rows { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.rows li {
|
|
||||||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
|
||||||
padding: 10px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-s);
|
|
||||||
}
|
|
||||||
.rows li:last-child { border-bottom: 0; }
|
|
||||||
.row-id { color: var(--muted); font-size: var(--fs-xs); min-width: 7.5em; }
|
|
||||||
.row-main { flex: 1; min-width: 12em; }
|
|
||||||
.row-date { color: var(--muted); font-size: var(--fs-xs); }
|
|
||||||
/* Status pills — neutral chrome; the label text carries the state */
|
|
||||||
.pill {
|
|
||||||
font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase;
|
|
||||||
padding: 3px 10px; border-radius: var(--radius-pill); border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.pill--ok { color: var(--galaxy); background: var(--moon); }
|
|
||||||
.pill--review { color: var(--text); background: #fff; }
|
|
||||||
.pill--draft { color: var(--muted); background: #fff; }
|
|
||||||
|
|
||||||
/* Auth (login mock) — nebula backdrop + floating card; the ONLY navy band
|
/* Auth (login mock) — nebula backdrop + floating card; the ONLY navy band
|
||||||
besides the home hero */
|
besides the home hero */
|
||||||
@@ -740,10 +712,6 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
|
|||||||
}
|
}
|
||||||
/* Logo ≥100px digital (BRAND B.1.3): 26px height ≈ 125px wordmark */
|
/* Logo ≥100px digital (BRAND B.1.3): 26px height ≈ 125px wordmark */
|
||||||
.auth-logo { height: 26px; width: auto; margin-bottom: var(--space-m); align-self: flex-start; }
|
.auth-logo { height: 26px; width: auto; margin-bottom: var(--space-m); align-self: flex-start; }
|
||||||
/* Whisper badge sits top-right, off the reading flow (back in flow ≤480px
|
|
||||||
where it would collide with the logo) */
|
|
||||||
.auth-card .badge-mock { position: absolute; top: var(--space-l); right: var(--space-l); margin: 0; }
|
|
||||||
@media (max-width: 480px) { .auth-card .badge-mock { position: static; margin-bottom: 14px; } }
|
|
||||||
/* Hierarchy per BRAND D.3.4: SG-medium Galaxy headline, Inter body */
|
/* Hierarchy per BRAND D.3.4: SG-medium Galaxy headline, Inter body */
|
||||||
.auth-card h1 { color: var(--galaxy); margin: 0; font-size: var(--fs-h2); }
|
.auth-card h1 { color: var(--galaxy); margin: 0; font-size: var(--fs-h2); }
|
||||||
.auth-card .muted { color: var(--muted); font-size: var(--fs-s); margin: 0; }
|
.auth-card .muted { color: var(--muted); font-size: var(--fs-s); margin: 0; }
|
||||||
@@ -790,7 +758,7 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
|
|||||||
/* NO cross-document view transitions: the root crossfade snapshots the new
|
/* NO cross-document view transitions: the root crossfade snapshots the new
|
||||||
page at its first render opportunity — on slow networks that is a half-
|
page at its first render opportunity — on slow networks that is a half-
|
||||||
parsed page, so every navigation reads as a full-page blink. Plain MPA
|
parsed page, so every navigation reads as a full-page blink. Plain MPA
|
||||||
paint + prefetch is the intended feel (see DESIGN.md, Motion policy). */
|
paint + prefetch is the intended feel (motion policy: this block is the only place animations live). */
|
||||||
html { scroll-behavior: smooth; }
|
html { scroll-behavior: smooth; }
|
||||||
/* The auth band hands its glow to a roaming layer inside the .band-bg clip
|
/* The auth band hands its glow to a roaming layer inside the .band-bg clip
|
||||||
frame (same --nebula stack); the photo hero keeps its scrimmed photo and
|
frame (same --nebula stack); the photo hero keeps its scrimmed photo and
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/base",
|
"extends": "astro/tsconfigs/strict",
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
"exclude": ["dist"]
|
"exclude": ["dist", "public"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user