7 Commits

Author SHA1 Message Date
H1K0 528b7004e3 fix(frontend): open original only when tapping the preview image
deploy / deploy (push) Successful in 1m24s
The preview area was one big link, so a click anywhere in it — including
the black letterbox margins — opened the original. Make the link
pointer-events:none and re-enable pointer-events on the image itself, so
"open original" fires only on the image, not the surrounding margins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:36:16 +03:00
H1K0 41c4667eb4 feat(frontend): enlarge viewer prev/next into full-height tap zones
Paging previously required hitting a small arrow circle pinned near each
edge. Replace them with full-height left/right tap zones (30%, capped at
220px) so a tap anywhere on that side pages. The arrow stays as a chip
near the edge with a hover-only hint gradient; the zones sit above the
image, and the "Replacing…" overlay is lifted above them so an in-flight
content replace still blocks paging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:35:47 +03:00
H1K0 c3ed36f178 fix(frontend): fit PWA icons into maskable safe zone
deploy / deploy (push) Successful in 1m20s
Android's adaptive-icon mask clipped the bamboo artwork and showed a
white backing on install. Fix the icon set so it survives masking.

- maskable icons: artwork fitted inside the 0.40W safe-zone circle,
  opaque full-bleed background (no transparency, so no white fill)
- any icons: rounded-corner tiles with transparent corners
- drop legacy transparent android-icon-* (RealFaviconGenerator) that
  Android picked up as adaptive icons and backed with white
- rename to icon-tile-*/icon-maskable-* so the manifest src changes
  and installed PWAs re-fetch the launcher icon

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 19:50:57 +03:00
H1K0 93e55e8e37 fix(frontend): show content date in local time, not UTC
deploy / deploy (push) Successful in 1m19s
The datetime-local input was populated by slicing the raw UTC ISO
string, so a saved time appeared shifted by the local offset (e.g.
12:00 Moscow rendered as 09:00). Convert the UTC instant to local
wall-clock on load via isoToLocalInput; the save path already
round-trips local -> UTC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:41:17 +03:00
H1K0 3f12b60261 fix(frontend): generate API types before svelte-check
deploy / deploy (push) Successful in 2m13s
schema.ts is gitignored and generated by `generate:types`, which only
`dev` and `build` ran as a prestep — not `check`. The deploy runs
`npm run check` before `npm run build`, so on a clean checkout (where
schema.ts is absent) check fails with "Cannot find module './schema'"
before build ever regenerates it. Give check (and check:watch) the same
generate:types prestep so it stands on its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:22:48 +03:00
H1K0 6d13fa425a fix(backend): swap db name in URL-style test DSN
deploy / deploy (push) Failing after 54s
The integration suite creates a per-run database and rewrites the admin
DSN to point at it via replaceDSNDatabase. The URL-style branch was a
stub that returned the DSN unchanged, so with a URL DSN every test ran
against the shared `postgres` database instead of its own — no isolation.
The first test to create a given user/object won; the rest collided on
unique constraints, surfacing as cascades of 500s (and ImportFromFolder
seeing accumulated rows).

CI passes TANABATA_TEST_ADMIN_DSN in URL form, so this only broke in the
deploy pipeline, not local key=value runs. Parse the URL and swap its
path to the per-run database.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:13:40 +03:00
H1K0 a0a987d561 ci(project): run the test suite before deploying
deploy / deploy (push) Failing after 1m35s
Gate the deploy on tests. After pulling master and before building the
image, run the full Go suite (incl. integration tests, against an ephemeral
Postgres) and the frontend type-check + build. Everything runs inside
throwaway toolchain containers, so the host still needs only docker and a
red build never reaches production. Update DEPLOY.md to reflect the gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:57:16 +03:00
20 changed files with 151 additions and 81 deletions
+44 -1
View File
@@ -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).
+7 -1
View File
@@ -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
} }
+6 -3
View File
@@ -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**
+2 -2
View File
@@ -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,13 +499,14 @@
</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"
> >
<span class="nav-chip">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path <path
d="M11 3L5 9L11 15" d="M11 3L5 9L11 15"
@@ -505,14 +516,16 @@
stroke-linejoin="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"
> >
<span class="nav-chip">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path <path
d="M7 3L13 9L7 15" d="M7 3L13 9L7 15"
@@ -522,6 +535,7 @@
stroke-linejoin="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 ---- */
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

+4 -29
View File
@@ -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"