Compare commits
7 Commits
3e04da3926
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 528b7004e3 | |||
| 41c4667eb4 | |||
| c3ed36f178 | |||
| 93e55e8e37 | |||
| 3f12b60261 | |||
| 6d13fa425a | |||
| a0a987d561 |
@@ -19,7 +19,9 @@ jobs:
|
|||||||
# existing clone in /opt/tanabata. See docs/DEPLOY.md for runner setup.
|
# existing clone in /opt/tanabata. See docs/DEPLOY.md for runner setup.
|
||||||
#
|
#
|
||||||
# Only shell steps here (no `uses:` actions), so the host needs git + docker
|
# Only shell steps here (no `uses:` actions), so the host needs git + docker
|
||||||
# and nothing else — no node, no rsync.
|
# and nothing else — no node, no go, no rsync. The test step below runs the
|
||||||
|
# Go and Node toolchains inside throwaway containers, so nothing has to be
|
||||||
|
# installed on the host.
|
||||||
runs-on: host
|
runs-on: host
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -35,6 +37,47 @@ jobs:
|
|||||||
git fetch --prune origin
|
git fetch --prune origin
|
||||||
git reset --hard origin/master
|
git reset --hard origin/master
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: /opt/tanabata
|
||||||
|
# Everything runs INSIDE throwaway toolchain containers — the host only
|
||||||
|
# needs docker (which it already uses for `docker compose`). Nothing (Go,
|
||||||
|
# Node, Postgres, vips/ffmpeg/exiftool) has to be installed on the host.
|
||||||
|
# The orchestration below uses only bash builtins + docker. A failure here
|
||||||
|
# fails the job, so a red build never reaches production. Module/npm caches
|
||||||
|
# persist in named volumes for speed.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker rm -f tfm-ci-db >/dev/null 2>&1 || true
|
||||||
|
docker network rm tfm-ci-net >/dev/null 2>&1 || true
|
||||||
|
docker network create tfm-ci-net >/dev/null
|
||||||
|
trap 'docker rm -f tfm-ci-db >/dev/null 2>&1 || true; docker network rm tfm-ci-net >/dev/null 2>&1 || true' EXIT
|
||||||
|
|
||||||
|
docker run -d --name tfm-ci-db --network tfm-ci-net \
|
||||||
|
-e POSTGRES_PASSWORD=postgres postgres:14-alpine >/dev/null
|
||||||
|
# Wait for Postgres, using pg_isready inside the DB container (no host tools).
|
||||||
|
for ((i = 0; i < 30; i++)); do
|
||||||
|
docker exec tfm-ci-db pg_isready -U postgres >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Backend: full suite, including the integration tests, against the
|
||||||
|
# ephemeral Postgres. -buildvcs=false since the .git dir isn't mounted.
|
||||||
|
docker run --rm --network tfm-ci-net \
|
||||||
|
-v /opt/tanabata/backend:/src -w /src \
|
||||||
|
-v tfm-ci-gomod:/go/pkg/mod -v tfm-ci-gocache:/root/.cache/go-build \
|
||||||
|
-e CGO_ENABLED=0 \
|
||||||
|
-e TANABATA_TEST_ADMIN_DSN="postgres://postgres:postgres@tfm-ci-db:5432/postgres?sslmode=disable" \
|
||||||
|
golang:1.26-alpine go test -buildvcs=false -count=1 ./...
|
||||||
|
|
||||||
|
# Frontend: type-check + production build (also validates openapi via
|
||||||
|
# the generate:types prestep).
|
||||||
|
docker run --rm \
|
||||||
|
-v /opt/tanabata:/repo -w /repo/frontend \
|
||||||
|
-v tfm-ci-npm:/root/.npm \
|
||||||
|
node:22-alpine sh -c "npm ci && npm run check && npm run build"
|
||||||
|
|
||||||
- name: Build image and start the stack
|
- name: Build image and start the stack
|
||||||
working-directory: /opt/tanabata
|
working-directory: /opt/tanabata
|
||||||
# .env must already exist in DEPLOY_DIR on the host (secrets + DB mode).
|
# .env must already exist in DEPLOY_DIR on the host (secrets + DB mode).
|
||||||
|
|||||||
@@ -1614,7 +1614,13 @@ func replaceDSNDatabase(dsn, newDB string) string {
|
|||||||
}
|
}
|
||||||
return dsn + " dbname=" + newDB
|
return dsn + " dbname=" + newDB
|
||||||
}
|
}
|
||||||
// URL style: not used in our defaults, but handled for completeness.
|
// URL style (e.g. postgres://user:pass@host:port/dbname?opts): the database
|
||||||
|
// is the URL path. CI passes this form via TANABATA_TEST_ADMIN_DSN, so it
|
||||||
|
// must swap the path to point the suite at its per-run database.
|
||||||
|
if u, err := url.Parse(dsn); err == nil {
|
||||||
|
u.Path = "/" + newDB
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
return dsn
|
return dsn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,16 @@
|
|||||||
Tanabata is deployed by a [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|
Tanabata is deployed by a [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
|
||||||
workflow ([`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml)) that
|
workflow ([`.gitea/workflows/deploy.yml`](../.gitea/workflows/deploy.yml)) that
|
||||||
runs on the **production host itself**. On every push to `master` it updates the
|
runs on the **production host itself**. On every push to `master` it updates the
|
||||||
git clone in `/opt/tanabata` and runs `docker compose up -d --build` there, so the
|
git clone in `/opt/tanabata`, runs the test suite (backend + frontend, in
|
||||||
image is built from the freshly-pushed code and the stack is restarted.
|
throwaway toolchain containers), and — only if it passes — runs
|
||||||
|
`docker compose up -d --build` there, so the image is built from the
|
||||||
|
freshly-pushed code and the stack is restarted.
|
||||||
|
|
||||||
```
|
```
|
||||||
push master ──> Gitea (container) ──> act_runner (host, "host" label)
|
push master ──> Gitea (container) ──> act_runner (host, "host" label)
|
||||||
│ git fetch + reset --hard (in /opt/tanabata)
|
│ git fetch + reset --hard (in /opt/tanabata)
|
||||||
└ docker compose up -d --build
|
│ run tests (go + node in containers; ephemeral Postgres)
|
||||||
|
└ docker compose up -d --build (only if tests pass)
|
||||||
```
|
```
|
||||||
|
|
||||||
The Gitea server runs in a container, but the **runner runs directly on the host**
|
The Gitea server runs in a container, but the **runner runs directly on the host**
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
"build": "npm run generate:types && vite build",
|
"build": "npm run generate:types && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "npm run generate:types && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "npm run generate:types && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,6 +89,18 @@
|
|||||||
if (previewSrc) URL.revokeObjectURL(previewSrc);
|
if (previewSrc) URL.revokeObjectURL(previewSrc);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// content_datetime is stored/returned as a UTC ISO instant, but
|
||||||
|
// <input type="datetime-local"> works in local wall-clock time with no zone.
|
||||||
|
// Shift by the local offset so the field shows local time; the save path
|
||||||
|
// (new Date(value).toISOString()) parses it back as local and re-encodes UTC.
|
||||||
|
function isoToLocalInput(iso?: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||||
|
return local.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm (local)
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFile(id: string) {
|
async function loadFile(id: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -101,9 +113,7 @@
|
|||||||
if (fileId !== id) return; // paged on; ignore
|
if (fileId !== id) return; // paged on; ignore
|
||||||
file = fileData;
|
file = fileData;
|
||||||
notes = fileData.notes ?? '';
|
notes = fileData.notes ?? '';
|
||||||
contentDatetime = fileData.content_datetime
|
contentDatetime = isoToLocalInput(fileData.content_datetime);
|
||||||
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
|
|
||||||
: '';
|
|
||||||
isPublic = fileData.is_public ?? false;
|
isPublic = fileData.is_public ?? false;
|
||||||
metadataNodes = objectToNodes(fileData.metadata);
|
metadataNodes = objectToNodes(fileData.metadata);
|
||||||
dirty = false;
|
dirty = false;
|
||||||
@@ -489,39 +499,43 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Prev / Next -->
|
<!-- Prev / Next: the whole left/right side of the preview is a tap zone -->
|
||||||
{#if prevId}
|
{#if prevId}
|
||||||
<button
|
<button
|
||||||
class="nav-btn nav-prev"
|
class="nav-zone nav-prev"
|
||||||
onclick={() => prevId && onNavigate(prevId)}
|
onclick={() => prevId && onNavigate(prevId)}
|
||||||
aria-label="Previous file"
|
aria-label="Previous file"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<span class="nav-chip">
|
||||||
<path
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
d="M11 3L5 9L11 15"
|
<path
|
||||||
stroke="currentColor"
|
d="M11 3L5 9L11 15"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
stroke-linecap="round"
|
stroke-width="2"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
/>
|
stroke-linejoin="round"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if nextId}
|
{#if nextId}
|
||||||
<button
|
<button
|
||||||
class="nav-btn nav-next"
|
class="nav-zone nav-next"
|
||||||
onclick={() => nextId && onNavigate(nextId)}
|
onclick={() => nextId && onNavigate(nextId)}
|
||||||
aria-label="Next file"
|
aria-label="Next file"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
<span class="nav-chip">
|
||||||
<path
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
d="M7 3L13 9L7 15"
|
<path
|
||||||
stroke="currentColor"
|
d="M7 3L13 9L7 15"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
stroke-linecap="round"
|
stroke-width="2"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
/>
|
stroke-linejoin="round"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -746,15 +760,17 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Whole preview area is a link: click opens the original in a new tab. */
|
/* The link fills the area for centring, but only the image itself is
|
||||||
|
clickable (pointer-events below) — tapping the black margins does nothing,
|
||||||
|
so "open original" fires only on the preview. */
|
||||||
.preview-link {
|
.preview-link {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: zoom-in;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-img {
|
.preview-img {
|
||||||
@@ -762,12 +778,14 @@
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
display: block;
|
display: block;
|
||||||
|
cursor: zoom-in;
|
||||||
|
pointer-events: auto; /* only the image opens the original */
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-busy {
|
.preview-busy {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 2;
|
z-index: 4; /* above the nav zones — an in-flight replace blocks paging */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -808,33 +826,58 @@
|
|||||||
background-color: #1a1010;
|
background-color: #1a1010;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Nav buttons ---- */
|
/* ---- Nav zones ----
|
||||||
.nav-btn {
|
Each covers the full-height left/right portion of the preview so paging
|
||||||
|
only needs a tap on that side, not a precise hit on the arrow. They sit
|
||||||
|
above the image, so over a full-width image the sides page and the centre
|
||||||
|
still opens the original. The dark hint gradient shows on hover only, to
|
||||||
|
keep photos clean on touch where there is no hover. */
|
||||||
|
.nav-zone {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 0;
|
||||||
transform: translateY(-50%);
|
bottom: 0;
|
||||||
width: 40px;
|
width: 30%;
|
||||||
height: 40px;
|
max-width: 220px;
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
border: none;
|
||||||
background-color: rgba(0, 0, 0, 0.55);
|
background: none;
|
||||||
color: #fff;
|
padding: 0 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s;
|
z-index: 3;
|
||||||
}
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-prev {
|
.nav-prev {
|
||||||
left: 10px;
|
left: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
.nav-next {
|
.nav-next {
|
||||||
right: 10px;
|
right: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-prev:hover {
|
||||||
|
background: linear-gradient(to right, rgba(0, 0, 0, 0.3), transparent);
|
||||||
|
}
|
||||||
|
.nav-next:hover {
|
||||||
|
background: linear-gradient(to left, rgba(0, 0, 0, 0.3), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-zone:hover .nav-chip {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Metadata panel ---- */
|
/* ---- Metadata panel ---- */
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 36 KiB |
@@ -10,50 +10,25 @@
|
|||||||
"theme_color": "#312F45",
|
"theme_color": "#312F45",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/android-icon-36x36.png",
|
"src": "/images/icon-tile-192x192.png",
|
||||||
"sizes": "36x36",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/images/android-icon-48x48.png",
|
|
||||||
"sizes": "48x48",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/images/android-icon-72x72.png",
|
|
||||||
"sizes": "72x72",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/images/android-icon-96x96.png",
|
|
||||||
"sizes": "96x96",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/images/android-icon-144x144.png",
|
|
||||||
"sizes": "144x144",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/images/pwa-192x192.png",
|
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/pwa-512x512.png",
|
"src": "/images/icon-tile-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/pwa-maskable-192x192.png",
|
"src": "/images/icon-maskable-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/images/pwa-maskable-512x512.png",
|
"src": "/images/icon-maskable-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
|
|||||||