release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
@@ -49,8 +49,9 @@ export default function AdminShell({ children }: { children: ReactNode }) {
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
<div className="admin-loading" role="status">
|
||||
<div className="admin-spinner" aria-hidden="true" />
|
||||
<span className="sr-only">Loading admin panel</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
58
app/admin/__tests__/AdminOverview.test.tsx
Normal file
58
app/admin/__tests__/AdminOverview.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/react';
|
||||
import AdminOverview from '../components/AdminOverview';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Every admin endpoint answers 404 except gallery.yaml, which holds `gallery`. */
|
||||
function stubGallery(gallery: unknown) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) =>
|
||||
url === '/api/admin/gallery'
|
||||
? new Response(JSON.stringify({ gallery, version: 'v' }), { status: 200 })
|
||||
: new Response(JSON.stringify({ error: 'nope' }), { status: 404 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The "Pages" tile: its number and the note under it, once loaded. */
|
||||
async function pagesTile() {
|
||||
const label = await screen.findByText('Pages');
|
||||
const tile = label.closest('a')!;
|
||||
const value = () => tile.querySelector('.overview-stat-value')!.textContent;
|
||||
await waitFor(() => expect(value()).not.toBe('–'));
|
||||
return { value: value(), note: tile.querySelector('.overview-stat-note')!.textContent };
|
||||
}
|
||||
|
||||
describe('AdminOverview — Pages tile', () => {
|
||||
it('counts subpages written in the older map form of gallery.yaml', async () => {
|
||||
// `subpages:` as a map of name → albums (or name → object) is still a
|
||||
// valid gallery.yaml — getConfig() and the page builder both read it.
|
||||
// The overview called .filter() on it, and the first admin screen crashed.
|
||||
stubGallery({
|
||||
subpages: {
|
||||
Weddings: ['11111111-1111-4111-8111-111111111111'],
|
||||
Travel: { albums: ['22222222-2222-4222-8222-222222222222'], enabled: false },
|
||||
},
|
||||
});
|
||||
render(<AdminOverview />);
|
||||
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
|
||||
});
|
||||
|
||||
it('leaves content-page menu entries out of the list form', async () => {
|
||||
stubGallery({
|
||||
subpages: [
|
||||
{ name: 'Weddings', albums: ['11111111-1111-4111-8111-111111111111'] },
|
||||
{ page: 'pricing' },
|
||||
{ name: 'Old', albums: [], enabled: false },
|
||||
],
|
||||
});
|
||||
render(<AdminOverview />);
|
||||
expect(await pagesTile()).toEqual({ value: '1', note: '1 switched off' });
|
||||
});
|
||||
});
|
||||
76
app/admin/__tests__/AssetPicker.test.tsx
Normal file
76
app/admin/__tests__/AssetPicker.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
|
||||
import AssetPicker from '../components/AssetPicker';
|
||||
|
||||
/**
|
||||
* A fetch whose answers the test hands out by hand, in any order — the
|
||||
* situation of a slow request still running when the operator moves on.
|
||||
*/
|
||||
function deferredFetch() {
|
||||
const pending: Array<{ url: string; resolve: (body: unknown) => void }> = [];
|
||||
const fetchMock = vi.fn(
|
||||
(url: string) =>
|
||||
new Promise<Response>((resolve) => {
|
||||
pending.push({
|
||||
url,
|
||||
resolve: (body) =>
|
||||
resolve({ ok: true, status: 200, json: async () => body } as unknown as Response),
|
||||
});
|
||||
}),
|
||||
);
|
||||
/** Answer the first open request whose URL contains `match`. */
|
||||
const answer = async (match: string, body: unknown) => {
|
||||
const i = pending.findIndex((p) => p.url.includes(match));
|
||||
if (i === -1) throw new Error(`no pending request for ${match}`);
|
||||
const [req] = pending.splice(i, 1);
|
||||
await act(async () => req.resolve(body));
|
||||
};
|
||||
return { fetchMock, answer };
|
||||
}
|
||||
|
||||
const asset = (id: string, name: string) => ({
|
||||
id,
|
||||
originalFileName: name,
|
||||
fileCreatedAt: '2024-05-17T10:00:00.000Z',
|
||||
isFavorite: false,
|
||||
});
|
||||
|
||||
let net: ReturnType<typeof deferredFetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
net = deferredFetch();
|
||||
vi.stubGlobal('fetch', net.fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('AssetPicker', () => {
|
||||
it('shows the tab it is on when an older request answers last', async () => {
|
||||
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
|
||||
// Opens on Favorites; the operator switches to All Photos before it loads.
|
||||
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
|
||||
|
||||
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
|
||||
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
|
||||
|
||||
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the spinner until the current request answers', async () => {
|
||||
render(<AssetPicker onSelect={() => {}} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /All Photos/ }));
|
||||
|
||||
// The stale Favorites answer must not end the All Photos loading state.
|
||||
await net.answer('favorites=true', { assets: [asset('f1', 'fav.jpg')], nextPage: null });
|
||||
expect(screen.queryByRole('button', { name: /fav\.jpg/ })).toBeNull();
|
||||
expect(screen.queryByText(/No favorite photos found/)).toBeNull();
|
||||
|
||||
await net.answer('favorites=false', { assets: [asset('a1', 'all.jpg')], nextPage: null });
|
||||
expect(screen.getByRole('button', { name: /all\.jpg/ })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ConfirmProvider, useConfirm } from '../components/ConfirmDialog';
|
||||
import { ConfirmProvider, useChoice, useConfirm } from '../components/ConfirmDialog';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -60,3 +60,46 @@ describe('ConfirmDialog', () => {
|
||||
await waitFor(() => expect(results).toEqual([false, false]));
|
||||
});
|
||||
});
|
||||
|
||||
/** The concurrent-edit prompt (#601): more than two ways out. */
|
||||
function ChoiceTrigger({ onResult }: { onResult: (id: string | null) => void }) {
|
||||
const choose = useChoice();
|
||||
return (
|
||||
<button
|
||||
onClick={async () =>
|
||||
onResult(
|
||||
await choose({
|
||||
title: 'Changed elsewhere',
|
||||
actions: [
|
||||
{ id: 'overwrite', label: 'Overwrite anyway' },
|
||||
{ id: 'reload', label: 'Reload (discard mine)' },
|
||||
],
|
||||
cancelLabel: 'Keep editing',
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
choose
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useChoice', () => {
|
||||
it('resolves to the picked action, or null on cancel', async () => {
|
||||
const results: (string | null)[] = [];
|
||||
render(
|
||||
<ConfirmProvider>
|
||||
<ChoiceTrigger onResult={(id) => results.push(id)} />
|
||||
</ConfirmProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('choose'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reload (discard mine)' }));
|
||||
await waitFor(() => expect(results).toEqual(['reload']));
|
||||
fireEvent.click(screen.getByText('choose'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Overwrite anyway' }));
|
||||
await waitFor(() => expect(results).toEqual(['reload', 'overwrite']));
|
||||
fireEvent.click(screen.getByText('choose'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep editing' }));
|
||||
await waitFor(() => expect(results).toEqual(['reload', 'overwrite', null]));
|
||||
});
|
||||
});
|
||||
|
||||
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal file
57
app/admin/__tests__/NewPageDialog.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import NewPageDialog from '../components/page-builder/NewPageDialog';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const taken = { subpages: [], albums: [], journal: [] };
|
||||
|
||||
function setup() {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<NewPageDialog
|
||||
taken={taken}
|
||||
existingPageSlugs={[]}
|
||||
creating={false}
|
||||
onCreate={onCreate}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
return { onCreate, slug: screen.getByLabelText('URL') as HTMLInputElement };
|
||||
}
|
||||
|
||||
/** Type one key at a time into a controlled input, as a person does. */
|
||||
function typeKeys(input: HTMLInputElement, text: string) {
|
||||
for (const ch of text) fireEvent.change(input, { target: { value: input.value + ch } });
|
||||
}
|
||||
|
||||
describe('NewPageDialog slug field', () => {
|
||||
it('takes a hyphen typed between two words', () => {
|
||||
const { slug, onCreate } = setup();
|
||||
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'About' } });
|
||||
// Select all and retype: the first key replaces the derived slug.
|
||||
fireEvent.change(slug, { target: { value: 'a' } });
|
||||
typeKeys(slug, 'bout-us');
|
||||
expect(slug.value).toBe('about-us');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'about-us' }));
|
||||
});
|
||||
|
||||
it('creates the finished slug when the field ends in a hyphen', () => {
|
||||
const { slug, onCreate } = setup();
|
||||
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Pricing' } });
|
||||
typeKeys(slug, '-');
|
||||
expect(slug.value).toBe('pricing-');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create page' }));
|
||||
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ slug: 'pricing' }));
|
||||
});
|
||||
|
||||
it('derives a readable slug from a German title', () => {
|
||||
const { slug } = setup();
|
||||
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Preise für Paare' } });
|
||||
expect(slug.value).toBe('preise-fur-paare');
|
||||
});
|
||||
});
|
||||
35
app/admin/__tests__/analyticsDays.test.ts
Normal file
35
app/admin/__tests__/analyticsDays.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { lastDays } from '../components/analyticsDays';
|
||||
|
||||
/**
|
||||
* "Last 14 Days" used to show the last 14 days that had any views, so quiet
|
||||
* days vanished and the chart could span a month (QA A-16).
|
||||
*/
|
||||
describe('lastDays', () => {
|
||||
const days = {
|
||||
'2026-08-28': { pageviews: 4 },
|
||||
'2026-09-20': { pageviews: 6 },
|
||||
'2026-09-29': { pageviews: 16 },
|
||||
};
|
||||
|
||||
it('returns every calendar day up to today, with 0 for days without views', () => {
|
||||
const out = lastDays(days, 14, new Date('2026-09-29T12:00:00Z'));
|
||||
expect(out).toHaveLength(14);
|
||||
expect(out[0]).toEqual({ date: '2026-09-16', pageviews: 0 });
|
||||
expect(out[4]).toEqual({ date: '2026-09-20', pageviews: 6 });
|
||||
expect(out[13]).toEqual({ date: '2026-09-29', pageviews: 16 });
|
||||
// Older recorded days are outside the window, not pulled into it.
|
||||
expect(out.find((d) => d.date === '2026-08-28')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('counts days in UTC, the way the tracker keys them', () => {
|
||||
// 23:30 UTC on the 29th is already the 30th in Berlin.
|
||||
const out = lastDays(days, 2, new Date('2026-09-29T23:30:00Z'));
|
||||
expect(out.map((d) => d.date)).toEqual(['2026-09-28', '2026-09-29']);
|
||||
});
|
||||
|
||||
it('crosses a month boundary without skipping or repeating a day', () => {
|
||||
const out = lastDays({}, 3, new Date('2026-10-01T00:00:00Z'));
|
||||
expect(out.map((d) => d.date)).toEqual(['2026-09-29', '2026-09-30', '2026-10-01']);
|
||||
});
|
||||
});
|
||||
@@ -110,3 +110,67 @@ describe('reading', () => {
|
||||
expect(ops.followMovedIndex(open, from, to)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('menu with content pages (#722)', () => {
|
||||
const withPages = (): GalleryState => ({
|
||||
...state(),
|
||||
// pricing before Japan, faq between Trips and Korea.
|
||||
pageRefs: [
|
||||
{ slug: 'pricing', position: 0 },
|
||||
{ slug: 'faq', position: 2 },
|
||||
],
|
||||
});
|
||||
const labels = (g: GalleryState) =>
|
||||
ops.menuItems(g).map((i) => (i.kind === 'page' ? `page:${i.slug}` : g.subpages[i.index].name));
|
||||
|
||||
it('interleaves pages and subpages', () => {
|
||||
expect(labels(withPages())).toEqual(['page:pricing', 'Japan', 'Trips', 'page:faq', 'Korea']);
|
||||
});
|
||||
|
||||
it('moves a page between subpages', () => {
|
||||
const g = ops.moveMenuItem(withPages(), 0, 2);
|
||||
expect(labels(g)).toEqual(['Japan', 'Trips', 'page:pricing', 'page:faq', 'Korea']);
|
||||
});
|
||||
|
||||
it('moves a subpage past a page and follows the open sheet', () => {
|
||||
const before = withPages();
|
||||
// Japan (row 1) to the end (row 4).
|
||||
expect(ops.followMenuMove(before, 0, 1, 4)).toBe(2);
|
||||
const g = ops.moveMenuItem(before, 1, 4);
|
||||
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea', 'Japan']);
|
||||
expect(g.subpages.map((s) => s.name)).toEqual(['Trips', 'Korea', 'Japan']);
|
||||
});
|
||||
|
||||
it('puts a page in the menu at the end, or before a row, and takes it out', () => {
|
||||
let g = ops.setPageInMenu(state(), 'about-me', true);
|
||||
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
|
||||
g = ops.setPageInMenu(g, 'rates', true, 1);
|
||||
expect(labels(g)).toEqual(['Japan', 'page:rates', 'Trips', 'Korea', 'page:about-me']);
|
||||
g = ops.setPageInMenu(g, 'rates', false);
|
||||
expect(labels(g)).toEqual(['Japan', 'Trips', 'Korea', 'page:about-me']);
|
||||
expect(ops.isPageInMenu(g, 'rates')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps pages in place when a subpage is removed', () => {
|
||||
const g = ops.removeSubpage(withPages(), 0);
|
||||
expect(labels(g)).toEqual(['page:pricing', 'Trips', 'page:faq', 'Korea']);
|
||||
});
|
||||
|
||||
it('follows a rename', () => {
|
||||
const g = ops.renamePageRef(withPages(), 'faq', 'questions');
|
||||
expect(labels(g)).toContain('page:questions');
|
||||
expect(labels(g)).not.toContain('page:faq');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addHeroes (#602)', () => {
|
||||
it('appends a multi-pick in pick order, skipping duplicates', () => {
|
||||
expect(ops.addHeroes(state(), ['h9', 'h2', 'h8', 'h9']).hero).toEqual([
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h9',
|
||||
'h8',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,3 +160,29 @@ describe('subpage keys', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('content page references (#722)', () => {
|
||||
it('keeps `- page:` entries where they were among the subpages', () => {
|
||||
const yaml = {
|
||||
subpages: [
|
||||
{ page: 'pricing' },
|
||||
{ name: 'Japan', albums: [A] },
|
||||
{ page: 'faq' },
|
||||
{ name: 'Korea', albums: [B] },
|
||||
{ page: 'contact-me' },
|
||||
],
|
||||
};
|
||||
expect(roundTrip(yaml)).toEqual(yaml);
|
||||
});
|
||||
|
||||
it('parses page references apart from the subpages', () => {
|
||||
const g = parseGalleryYaml({ subpages: [{ name: 'Japan', albums: [A] }, { page: 'faq' }] });
|
||||
expect(g.subpages.map((s) => s.name)).toEqual(['Japan']);
|
||||
expect(g.pageRefs).toEqual([{ slug: 'faq', position: 1 }]);
|
||||
});
|
||||
|
||||
it('writes a menu of pages only', () => {
|
||||
const yaml = { subpages: [{ page: 'pricing' }] };
|
||||
expect(roundTrip(yaml)).toEqual(yaml);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { JournalBlock } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, parseJournalMarkdown } from '@/lib/journal';
|
||||
import { createBlock, moveBlock, isLegacyAssetRef } from '../components/journal/blockOps';
|
||||
import {
|
||||
createBlock,
|
||||
createPhotoBlocks,
|
||||
fillAssetSlots,
|
||||
moveBlock,
|
||||
isLegacyAssetRef,
|
||||
} from '../components/journal/blockOps';
|
||||
import {
|
||||
clampSplit,
|
||||
splitFromPointer,
|
||||
@@ -124,3 +130,21 @@ describe('split pane', () => {
|
||||
expect(parseStoredSplit('abc')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-pick into photo blocks (#602)', () => {
|
||||
it('fills empty slots first, then appends', () => {
|
||||
expect(fillAssetSlots(['a', '', ''], ['x', 'y', 'z'])).toEqual(['a', 'x', 'y', 'z']);
|
||||
});
|
||||
|
||||
it('never grows past a capacity', () => {
|
||||
expect(fillAssetSlots(['', ''], ['x', 'y', 'z'], 2)).toEqual(['x', 'y']);
|
||||
expect(fillAssetSlots(['a', ''], ['x'], 2)).toEqual(['a', 'x']);
|
||||
});
|
||||
|
||||
it('creates one contained photo block per asset', () => {
|
||||
expect(createPhotoBlocks(['x', 'y'])).toEqual([
|
||||
{ type: 'photo', assetId: 'x', caption: '', layout: 'contained' },
|
||||
{ type: 'photo', assetId: 'y', caption: '', layout: 'contained' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
24
app/admin/__tests__/page-link-slug.test.ts
Normal file
24
app/admin/__tests__/page-link-slug.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// `?page=<slug>` in /admin/pages selects a page, and the page panel turns the
|
||||
// slug into its "open live page" link. A slug such as `/evil.example` would
|
||||
// make that link `//evil.example`, which leaves the site. Both ends guard it.
|
||||
const read = (file: string) => readFileSync(path.join(process.cwd(), file), 'utf8');
|
||||
|
||||
describe('page link from ?page=', () => {
|
||||
it('only selects a page for a valid slug', () => {
|
||||
const src = read('app/admin/components/PageBuilder.tsx');
|
||||
const fn = src.slice(src.indexOf('function openPageFromLink'));
|
||||
const body = fn.slice(0, fn.indexOf('\n }\n'));
|
||||
expect(body).toMatch(/if \(!isValidSlug\(slug\)\) return;/);
|
||||
expect(body.indexOf('isValidSlug(slug)')).toBeLessThan(body.indexOf('setSelectedPage(slug)'));
|
||||
});
|
||||
|
||||
it('encodes the slug in the live-page link', () => {
|
||||
const src = read('app/admin/components/page-builder/PagePanel.tsx');
|
||||
expect(src).toContain('href={`/${encodeURIComponent(slug)}`}');
|
||||
expect(src).not.toContain('href={`/${slug}`}');
|
||||
});
|
||||
});
|
||||
@@ -50,9 +50,9 @@ describe('systemHealth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a doctor warning as neutral', () => {
|
||||
it('reports a doctor warning in the warning tone, not the neutral one', () => {
|
||||
expect(health({ doctorLevel: 'warn' })).toEqual({
|
||||
tone: 'unknown',
|
||||
tone: 'warning',
|
||||
label: 'Check Diagnostics',
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe('systemHealth', () => {
|
||||
'Setup Incomplete': 'unknown',
|
||||
'System Degraded': 'disconnected',
|
||||
'Needs Attention': 'disconnected',
|
||||
'Check Diagnostics': 'unknown',
|
||||
'Check Diagnostics': 'warning',
|
||||
'System OK': 'connected',
|
||||
'Status Unknown': 'unknown',
|
||||
};
|
||||
|
||||
@@ -34,4 +34,5 @@
|
||||
@import './styles/diagnostics.css';
|
||||
@import './styles/messages.css';
|
||||
@import './styles/notifications.css';
|
||||
@import './styles/proofing.css';
|
||||
@import './styles/redesign.css';
|
||||
|
||||
@@ -47,7 +47,7 @@ const NAV: {
|
||||
{
|
||||
label: 'Pages',
|
||||
href: '/admin/pages',
|
||||
match: /^\/admin\/pages$/,
|
||||
match: /^\/admin\/pages(\/|$)/,
|
||||
icon: <Icons.IconGrid size={16} />,
|
||||
},
|
||||
{
|
||||
@@ -68,6 +68,12 @@ const NAV: {
|
||||
icon: <Icons.IconFileText size={16} />,
|
||||
badge: 'messages',
|
||||
},
|
||||
{
|
||||
label: 'Proofing',
|
||||
href: '/admin/proofing',
|
||||
match: /^\/admin\/proofing/,
|
||||
icon: <Icons.IconHeart size={16} />,
|
||||
},
|
||||
{
|
||||
label: 'Analytics',
|
||||
href: '/admin/analytics',
|
||||
@@ -277,6 +283,9 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
menuOpen ? ' menu-open' : ''
|
||||
}`}
|
||||
>
|
||||
<a href="#admin-main" className="skip-link">
|
||||
Skip to content
|
||||
</a>
|
||||
<aside className="admin-sidebar" aria-label="Admin">
|
||||
<button
|
||||
type="button"
|
||||
@@ -506,7 +515,9 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="admin-main admin-workspace">{children}</main>
|
||||
<main id="admin-main" tabIndex={-1} className="admin-main admin-workspace">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<BackupManagerModal
|
||||
isOpen={showBackupModal}
|
||||
|
||||
@@ -15,9 +15,11 @@ import * as Icons from './Icons';
|
||||
import { adminGet } from './useAdminFetch';
|
||||
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
|
||||
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
|
||||
import { parseGalleryYaml } from './page-builder/galleryYaml';
|
||||
|
||||
interface OverviewData {
|
||||
gallery: { hero?: unknown; subpages?: Array<{ enabled?: boolean }> } | null;
|
||||
/** gallery.yaml as stored; `subpages` may be a list or the older name → albums map. */
|
||||
gallery: Record<string, unknown> | null;
|
||||
journal: Array<{ frontmatter: { draft?: boolean } }> | null;
|
||||
messages: Array<{ read: boolean; name: string }> | null;
|
||||
analytics: { days?: Record<string, { pageviews?: number }> } | null;
|
||||
@@ -32,6 +34,7 @@ const FIX_ROUTE: Record<string, string> = {
|
||||
passwords: '/admin/settings/security',
|
||||
'album-ids': '/admin/pages',
|
||||
'albums-shared': '/admin/pages',
|
||||
'content-pages': '/admin/pages',
|
||||
};
|
||||
|
||||
function viewsLastDays(
|
||||
@@ -85,7 +88,9 @@ export default function AdminOverview() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const subpages = data?.gallery?.subpages ?? [];
|
||||
// Read the way the page builder reads it: both shapes of `subpages:`, and
|
||||
// `- page:` entries, which are content pages in the menu, not subpages (#722).
|
||||
const subpages = data?.gallery ? parseGalleryYaml(data.gallery).subpages : [];
|
||||
const published = subpages.filter((sp) => sp.enabled !== false).length;
|
||||
const heroRaw = data?.gallery?.hero;
|
||||
const heroCount = Array.isArray(heroRaw) ? heroRaw.length : heroRaw ? 1 : 0;
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
||||
>
|
||||
<div className="picker-header">
|
||||
<h3 id="album-picker-title">Select Album</h3>
|
||||
<button className="admin-btn-icon" onClick={onClose}>
|
||||
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -76,12 +76,16 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
||||
{filtered.map((album) => {
|
||||
const isUsed = usedAlbumIds.has(album.id);
|
||||
return (
|
||||
<div
|
||||
// A button, not a div: the list has to work from the keyboard.
|
||||
// An album already on the site is disabled rather than inert.
|
||||
<button
|
||||
type="button"
|
||||
key={album.id}
|
||||
className={`picker-item ${isUsed ? 'used' : ''}`}
|
||||
onClick={() => !isUsed && onSelect(album.id)}
|
||||
disabled={isUsed}
|
||||
onClick={() => onSelect(album.id)}
|
||||
>
|
||||
<div className="picker-item-thumb">
|
||||
<span className="picker-item-thumb">
|
||||
{album.thumbnailAssetId ? (
|
||||
<img
|
||||
className="picker-thumb-img"
|
||||
@@ -90,12 +94,12 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="picker-thumb-placeholder">
|
||||
<span className="picker-thumb-placeholder">
|
||||
<IconFolder size={20} />
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="picker-item-info">
|
||||
</span>
|
||||
<span className="picker-item-info">
|
||||
<span className="picker-item-name">{album.albumName}</span>
|
||||
<span className="picker-item-meta">
|
||||
{album.assetCount} photos
|
||||
@@ -108,15 +112,15 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
||||
{album.shared === false && (
|
||||
<span className="picker-item-note">Not shared in Immich</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="picker-item-action">
|
||||
</span>
|
||||
<span className="picker-item-action">
|
||||
{isUsed ? (
|
||||
<span className="picker-used-badge">In use</span>
|
||||
) : (
|
||||
<span className="picker-select-badge">Select</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as Icons from './Icons';
|
||||
import { useAdminFetch } from './useAdminFetch';
|
||||
import AdminLoadState from './AdminLoadState';
|
||||
import PageHeader from './PageHeader';
|
||||
import { lastDays } from './analyticsDays';
|
||||
|
||||
interface AnalyticsData {
|
||||
trackingEnabled?: boolean;
|
||||
@@ -40,10 +41,9 @@ export default function AnalyticsView() {
|
||||
|
||||
const totalViews = data.summary?.totalViews || 0;
|
||||
const maxPageViews = data.topPages[0]?.count || 1;
|
||||
const daysList = Object.entries(data.days || {})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.slice(-14); // Last 14 days
|
||||
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
|
||||
const hasDays = Object.keys(data.days || {}).length > 0;
|
||||
const daysList = lastDays(data.days || {}, 14, new Date());
|
||||
const maxDayViews = Math.max(...daysList.map((d) => d.pageviews), 1);
|
||||
|
||||
const desktopRatio =
|
||||
data.devices.desktop + data.devices.mobile > 0
|
||||
@@ -105,24 +105,24 @@ export default function AnalyticsView() {
|
||||
<Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)
|
||||
</h3>
|
||||
</div>
|
||||
{daysList.length === 0 ? (
|
||||
{!hasDays ? (
|
||||
<p className="analytics-empty">
|
||||
No pageviews recorded yet. Visit portfolio pages to start tracking!
|
||||
</p>
|
||||
) : (
|
||||
<div className="analytics-bar-chart">
|
||||
{daysList.map(([dateStr, dayData]) => {
|
||||
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
|
||||
const formattedDate = dateStr.slice(5); // MM-DD
|
||||
{daysList.map(({ date, pageviews }) => {
|
||||
const heightPct = Math.round((pageviews / maxDayViews) * 100);
|
||||
const formattedDate = date.slice(5); // MM-DD
|
||||
return (
|
||||
<div
|
||||
key={dateStr}
|
||||
className="bar-col"
|
||||
title={`${dateStr}: ${dayData.pageviews} views`}
|
||||
>
|
||||
<div key={date} className="bar-col" title={`${date}: ${pageviews} views`}>
|
||||
<div className="bar-wrapper">
|
||||
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
|
||||
<span className="bar-val">{dayData.pageviews}</span>
|
||||
{/* A day with views keeps a sliver of bar; an empty one has none. */}
|
||||
<div
|
||||
className="bar-fill"
|
||||
style={{ height: `${pageviews > 0 ? Math.max(heightPct, 3) : 0}%` }}
|
||||
>
|
||||
<span className="bar-val">{pageviews}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bar-label">{formattedDate}</span>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
interface AssetInfo {
|
||||
id: string;
|
||||
@@ -65,8 +66,10 @@ function SortableAssetTile({
|
||||
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
|
||||
<span className="order-tile-index">{index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="order-tile-action"
|
||||
title="Unpin — let this photo follow automatically"
|
||||
aria-label={`Unpin ${asset.originalFileName}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -94,6 +97,7 @@ export default function AssetOrderEditor({
|
||||
onClose,
|
||||
}: Props) {
|
||||
useScrollLock(true);
|
||||
const cardRef = useModalDialog(onClose);
|
||||
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -164,10 +168,17 @@ export default function AssetOrderEditor({
|
||||
|
||||
return (
|
||||
<div className="picker-overlay" onClick={onClose}>
|
||||
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="asset-picker-modal"
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-editor-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="picker-header">
|
||||
<h3>Photo order — {albumName}</h3>
|
||||
<button className="admin-btn-icon" onClick={onClose}>
|
||||
<h3 id="order-editor-title">Photo order — {albumName}</h3>
|
||||
<button type="button" className="admin-btn-icon" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -240,19 +251,19 @@ export default function AssetOrderEditor({
|
||||
</p>
|
||||
<div className="order-editor-grid">
|
||||
{unpinned.map((asset) => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={asset.id}
|
||||
className="order-tile order-tile-unpinned"
|
||||
title={asset.originalFileName}
|
||||
aria-label={`Pin ${asset.originalFileName}`}
|
||||
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
|
||||
>
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${asset.id}`}
|
||||
alt={asset.originalFileName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="order-tile-action">+</span>
|
||||
</div>
|
||||
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
|
||||
<span className="order-tile-action" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
import { IconCheck, IconFolder, IconImage, IconStar } from './Icons';
|
||||
import { canSelectMore, matchesQuery, toggleSelection } from '@/lib/admin/assetSelection';
|
||||
import { IconCheck, IconFolder, IconImage, IconSearch, IconStar } from './Icons';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
|
||||
interface AssetInfo {
|
||||
@@ -13,7 +14,15 @@ interface AssetInfo {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelect: (assetId: string) => void;
|
||||
/** Single-select: called with the one asset picked; the caller closes the picker. */
|
||||
onSelect?: (assetId: string) => void;
|
||||
/**
|
||||
* Multi-select (#602): tiles toggle, and a confirm button hands over every
|
||||
* picked asset in the order they were picked. Takes precedence over `onSelect`.
|
||||
*/
|
||||
onSelectMany?: (assetIds: string[]) => void;
|
||||
/** Most assets `onSelectMany` accepts; unlimited when omitted. */
|
||||
max?: number;
|
||||
onClose: () => void;
|
||||
currentAssetIds?: string[];
|
||||
albumId?: string;
|
||||
@@ -22,8 +31,12 @@ interface Props {
|
||||
|
||||
type Tab = 'album' | 'favorites' | 'all';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
export default function AssetPicker({
|
||||
onSelect,
|
||||
onSelectMany,
|
||||
max,
|
||||
onClose,
|
||||
currentAssetIds = [],
|
||||
albumId,
|
||||
@@ -31,21 +44,50 @@ export default function AssetPicker({
|
||||
}: Props) {
|
||||
useScrollLock(true);
|
||||
const cardRef = useModalDialog(onClose);
|
||||
const multi = onSelectMany !== undefined;
|
||||
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
|
||||
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [queryInput, setQueryInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [uuidInput, setUuidInput] = useState('');
|
||||
const [uuidError, setUuidError] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const currentIds = new Set(currentAssetIds);
|
||||
/**
|
||||
* Bumped each time the list starts over (tab, album or search changed). A
|
||||
* request is only allowed to touch the list while its generation is current:
|
||||
* without this, a slow Favorites answer landing after a switch to All Photos
|
||||
* replaced that tab's photos, and ended its spinner early.
|
||||
*/
|
||||
const generation = useRef(0);
|
||||
const currentIds = useMemo(() => new Set(currentAssetIds), [currentAssetIds]);
|
||||
|
||||
// Debounce typing into the query the fetch runs on.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQuery(queryInput.trim()), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [queryInput]);
|
||||
|
||||
// The album tab loads the album whole and filters locally; the library tabs
|
||||
// search server-side, since they are paged.
|
||||
const serverQuery = tab === 'album' ? '' : query;
|
||||
|
||||
const loadAssets = useCallback(
|
||||
async (pageNum: number, append: boolean = false) => {
|
||||
if (pageNum === 1) setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
// A fresh list starts a generation; a further page belongs to the current one.
|
||||
const gen = append ? generation.current : ++generation.current;
|
||||
const current = () => gen === generation.current;
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
// A "load more" of the previous list will not clear this itself.
|
||||
setLoadingMore(false);
|
||||
}
|
||||
|
||||
try {
|
||||
let res: Response;
|
||||
@@ -56,22 +98,31 @@ export default function AssetPicker({
|
||||
page: pageNum.toString(),
|
||||
favorites: tab === 'favorites' ? 'true' : 'false',
|
||||
});
|
||||
if (serverQuery) params.set('q', serverQuery);
|
||||
res = await fetch(`/api/admin/assets?${params}`);
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
|
||||
if (!current()) return;
|
||||
setAssets((prev) => {
|
||||
if (!append) return data.assets;
|
||||
// Merged filename + description pages can overlap across pages.
|
||||
const seen = new Set(prev.map((a) => a.id));
|
||||
return [...prev, ...data.assets.filter((a: AssetInfo) => !seen.has(a.id))];
|
||||
});
|
||||
setHasMore(data.nextPage !== null);
|
||||
setPage(pageNum);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load assets:', err);
|
||||
if (current()) console.error('Failed to load assets:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
if (current()) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tab, albumId],
|
||||
[tab, albumId, serverQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,7 +130,9 @@ export default function AssetPicker({
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
loadAssets(1);
|
||||
}, [tab, loadAssets]);
|
||||
}, [loadAssets]);
|
||||
|
||||
const visible = tab === 'album' ? assets.filter((a) => matchesQuery(a, query)) : assets;
|
||||
|
||||
// Infinite scroll
|
||||
function handleScroll() {
|
||||
@@ -90,6 +143,16 @@ export default function AssetPicker({
|
||||
}
|
||||
}
|
||||
|
||||
function pick(id: string) {
|
||||
if (currentIds.has(id)) return;
|
||||
if (multi) setSelected((s) => toggleSelection(s, id, { disabled: currentIds, max }));
|
||||
else onSelect?.(id);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (multi && selected.length > 0) onSelectMany(selected);
|
||||
}
|
||||
|
||||
function handleUuidSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const uuid = uuidInput.trim();
|
||||
@@ -99,7 +162,12 @@ export default function AssetPicker({
|
||||
return;
|
||||
}
|
||||
setUuidError('');
|
||||
onSelect(uuid);
|
||||
if (multi) {
|
||||
setSelected((s) => toggleSelection(s, uuid.toLowerCase(), { disabled: currentIds, max }));
|
||||
setUuidInput('');
|
||||
} else {
|
||||
onSelect?.(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
@@ -114,6 +182,8 @@ export default function AssetPicker({
|
||||
}
|
||||
}
|
||||
|
||||
const full = multi && !canSelectMore(selected, max);
|
||||
|
||||
return (
|
||||
<div className="picker-overlay" onClick={onClose}>
|
||||
<div
|
||||
@@ -126,7 +196,7 @@ export default function AssetPicker({
|
||||
>
|
||||
<div className="picker-header">
|
||||
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
|
||||
<button className="admin-btn-icon" onClick={onClose}>
|
||||
<button className="admin-btn-icon" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -136,6 +206,7 @@ export default function AssetPicker({
|
||||
{albumId && (
|
||||
<button
|
||||
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
|
||||
aria-pressed={tab === 'album'}
|
||||
onClick={() => setTab('album')}
|
||||
>
|
||||
<IconFolder size={14} />
|
||||
@@ -144,6 +215,7 @@ export default function AssetPicker({
|
||||
)}
|
||||
<button
|
||||
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
|
||||
aria-pressed={tab === 'favorites'}
|
||||
onClick={() => setTab('favorites')}
|
||||
>
|
||||
<IconStar size={14} />
|
||||
@@ -151,6 +223,7 @@ export default function AssetPicker({
|
||||
</button>
|
||||
<button
|
||||
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
|
||||
aria-pressed={tab === 'all'}
|
||||
onClick={() => setTab('all')}
|
||||
>
|
||||
<IconImage size={14} />
|
||||
@@ -158,45 +231,91 @@ export default function AssetPicker({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="asset-picker-search">
|
||||
<IconSearch size={14} />
|
||||
<input
|
||||
type="search"
|
||||
value={queryInput}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter in the field searches at once instead of submitting anything.
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
setQuery(queryInput.trim());
|
||||
}
|
||||
}}
|
||||
maxLength={100}
|
||||
placeholder={
|
||||
tab === 'album'
|
||||
? 'Filter this album by filename…'
|
||||
: 'Search filename, description, or a date (2024-05)…'
|
||||
}
|
||||
aria-label="Search photos"
|
||||
className="asset-picker-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Asset Grid */}
|
||||
<div className="asset-picker-grid" ref={listRef} onScroll={handleScroll}>
|
||||
<div
|
||||
className="asset-picker-grid"
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
role="group"
|
||||
aria-label="Photos"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="asset-picker-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
) : visible.length === 0 ? (
|
||||
<p className="empty-hint">
|
||||
{tab === 'favorites'
|
||||
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
|
||||
: 'No photos found.'}
|
||||
{query
|
||||
? `No photos match “${query}”.`
|
||||
: tab === 'favorites'
|
||||
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
|
||||
: 'No photos found.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{assets.map((asset) => {
|
||||
{visible.map((asset) => {
|
||||
const isUsed = currentIds.has(asset.id);
|
||||
const order = selected.indexOf(asset.id);
|
||||
const isSelected = order !== -1;
|
||||
const blocked = isUsed || (full && !isSelected);
|
||||
const label = `${asset.originalFileName}, ${formatDate(asset.fileCreatedAt)}${
|
||||
isUsed ? ' (already used)' : ''
|
||||
}`;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={asset.id}
|
||||
className={`asset-picker-tile ${isUsed ? 'used' : ''}`}
|
||||
onClick={() => !isUsed && onSelect(asset.id)}
|
||||
className={`asset-picker-tile ${isUsed ? 'used' : ''} ${
|
||||
isSelected ? 'selected' : ''
|
||||
}`}
|
||||
onClick={() => pick(asset.id)}
|
||||
disabled={blocked}
|
||||
aria-pressed={multi ? isSelected : undefined}
|
||||
aria-label={label}
|
||||
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${asset.id}`}
|
||||
alt={asset.originalFileName}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
|
||||
{isUsed && (
|
||||
<div className="asset-picker-used-badge">
|
||||
<span className="asset-picker-used-badge" aria-hidden="true">
|
||||
<IconCheck size={11} />
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<span className="asset-picker-order-badge" aria-hidden="true">
|
||||
{max === 1 ? <IconCheck size={11} /> : order + 1}
|
||||
</span>
|
||||
)}
|
||||
{asset.isFavorite && !isUsed && (
|
||||
<div className="asset-picker-fav-badge" title="Favorite in Immich">
|
||||
<span className="asset-picker-fav-badge" aria-hidden="true">
|
||||
<IconStar size={14} />
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{loadingMore && (
|
||||
@@ -208,6 +327,36 @@ export default function AssetPicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection bar (multi-select) */}
|
||||
{multi && (
|
||||
<div className="asset-picker-selection">
|
||||
<span aria-live="polite">
|
||||
{selected.length === 0
|
||||
? max && max > 1
|
||||
? `Pick up to ${max} photos`
|
||||
: 'Pick photos'
|
||||
: `${selected.length}${max && max > 1 ? ` of ${max}` : ''} selected`}
|
||||
</span>
|
||||
{selected.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => setSelected([])}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
disabled={selected.length === 0}
|
||||
onClick={confirm}
|
||||
>
|
||||
{selected.length > 1 ? `Add ${selected.length} photos` : 'Add photo'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UUID Fallback */}
|
||||
<div className="asset-picker-uuid-section">
|
||||
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
|
||||
@@ -219,6 +368,7 @@ export default function AssetPicker({
|
||||
setUuidError('');
|
||||
}}
|
||||
placeholder="Or paste asset UUID directly..."
|
||||
aria-label="Asset UUID"
|
||||
className="asset-picker-uuid-input"
|
||||
/>
|
||||
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>
|
||||
|
||||
@@ -20,6 +20,7 @@ const TABS: { target: BackupTarget; label: string; file: string }[] = [
|
||||
{ target: 'about', label: 'About', file: 'about.md' },
|
||||
{ target: 'privacy', label: 'Privacy', file: 'privacy.md' },
|
||||
{ target: 'journal', label: 'Journal', file: 'journal entries' },
|
||||
{ target: 'pages', label: 'Content pages', file: 'content pages' },
|
||||
];
|
||||
|
||||
type BackupLists = Record<BackupTarget, BackupItem[]>;
|
||||
@@ -30,6 +31,7 @@ const EMPTY_LISTS: BackupLists = {
|
||||
about: [],
|
||||
privacy: [],
|
||||
journal: [],
|
||||
pages: [],
|
||||
};
|
||||
|
||||
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
|
||||
@@ -87,7 +89,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
|
||||
setSuccessMsg(
|
||||
item.slug
|
||||
? `Journal entry "${item.slug}" restored.`
|
||||
? `${item.target === 'pages' ? 'Page' : 'Journal entry'} "${item.slug}" restored.`
|
||||
: `Backup restored successfully! (${filename})`,
|
||||
);
|
||||
setConfirmItem(null);
|
||||
@@ -121,8 +123,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div>
|
||||
<h2 id="backup-modal-title">Backup History & Restoration</h2>
|
||||
<p className="backup-modal-subtitle">
|
||||
Restore an earlier version of your pages, settings, about page or journal — including
|
||||
deleted journal entries.
|
||||
Restore an earlier version of your pages, settings, about page, journal or content
|
||||
pages — including deleted journal entries and pages.
|
||||
</p>
|
||||
</div>
|
||||
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
|
||||
@@ -194,7 +196,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
|
||||
const formattedDate =
|
||||
dateObj && !isNaN(dateObj.getTime())
|
||||
? dateObj.toLocaleString('de-DE', {
|
||||
? dateObj.toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
@@ -251,8 +253,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
}}
|
||||
/>
|
||||
{showAllBackups
|
||||
? 'Ältere Einträge einklappen'
|
||||
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
|
||||
? 'Hide older backups'
|
||||
: `Show ${hiddenCount} older ${hiddenCount === 1 ? 'backup' : 'backups'}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,37 +28,90 @@ export interface ConfirmOptions {
|
||||
|
||||
type Confirm = (options: ConfirmOptions) => Promise<boolean>;
|
||||
|
||||
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
|
||||
const ConfirmContext = createContext<Confirm>(async (o) =>
|
||||
typeof window === 'undefined' ? false : window.confirm(o.title),
|
||||
);
|
||||
|
||||
export function useConfirm(): Confirm {
|
||||
return useContext(ConfirmContext);
|
||||
/** One button of a choice; `id` is what the promise resolves to. */
|
||||
export interface ChoiceAction<Id extends string> {
|
||||
id: Id;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState<ConfirmOptions | null>(null);
|
||||
const resolver = useRef<((ok: boolean) => void) | null>(null);
|
||||
/**
|
||||
* More than yes or no — the concurrent-edit prompt (#601) offers three ways
|
||||
* out. The cancel button (and Esc, and the backdrop) resolves to null.
|
||||
*/
|
||||
export interface ChoiceOptions<Id extends string> {
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
/** Left to right; the last one is the primary button. */
|
||||
actions: ChoiceAction<Id>[];
|
||||
cancelLabel?: string;
|
||||
}
|
||||
|
||||
const confirm = useCallback<Confirm>(
|
||||
(options) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolver.current?.(false);
|
||||
type Choose = <Id extends string>(options: ChoiceOptions<Id>) => Promise<Id | null>;
|
||||
|
||||
interface DialogContext {
|
||||
confirm: Confirm;
|
||||
choose: Choose;
|
||||
}
|
||||
|
||||
/** Outside the provider (a component tested on its own) it falls back to window.confirm. */
|
||||
const ConfirmContext = createContext<DialogContext>({
|
||||
confirm: async (o) => (typeof window === 'undefined' ? false : window.confirm(o.title)),
|
||||
choose: async (o) =>
|
||||
typeof window !== 'undefined' && window.confirm(o.title)
|
||||
? (o.actions[o.actions.length - 1]?.id ?? null)
|
||||
: null,
|
||||
});
|
||||
|
||||
export function useConfirm(): Confirm {
|
||||
return useContext(ConfirmContext).confirm;
|
||||
}
|
||||
|
||||
export function useChoice(): Choose {
|
||||
return useContext(ConfirmContext).choose;
|
||||
}
|
||||
|
||||
const CONFIRM_ID = 'confirm';
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [pending, setPending] = useState<ChoiceOptions<string> | null>(null);
|
||||
const resolver = useRef<((id: string | null) => void) | null>(null);
|
||||
|
||||
const choose = useCallback(
|
||||
(options: ChoiceOptions<string>) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
resolver.current?.(null);
|
||||
resolver.current = resolve;
|
||||
setPending(options);
|
||||
}),
|
||||
[],
|
||||
) as Choose;
|
||||
|
||||
const confirm = useCallback<Confirm>(
|
||||
async (options) =>
|
||||
(await choose({
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
cancelLabel: options.cancelLabel,
|
||||
actions: [
|
||||
{
|
||||
id: CONFIRM_ID,
|
||||
label: options.confirmLabel ?? 'Confirm',
|
||||
danger: options.danger,
|
||||
},
|
||||
],
|
||||
})) === CONFIRM_ID,
|
||||
[choose],
|
||||
);
|
||||
|
||||
const settle = useCallback((ok: boolean) => {
|
||||
resolver.current?.(ok);
|
||||
const settle = useCallback((id: string | null) => {
|
||||
resolver.current?.(id);
|
||||
resolver.current = null;
|
||||
setPending(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
<ConfirmContext.Provider value={{ confirm, choose }}>
|
||||
{children}
|
||||
{pending && <Dialog options={pending} onSettle={settle} />}
|
||||
</ConfirmContext.Provider>
|
||||
@@ -69,13 +122,14 @@ function Dialog({
|
||||
options,
|
||||
onSettle,
|
||||
}: {
|
||||
options: ConfirmOptions;
|
||||
onSettle: (ok: boolean) => void;
|
||||
options: ChoiceOptions<string>;
|
||||
onSettle: (id: string | null) => void;
|
||||
}) {
|
||||
// Esc cancels, focus is trapped and returned (the same hook the modals use).
|
||||
const cardRef = useModalDialog(() => onSettle(false));
|
||||
const cardRef = useModalDialog(() => onSettle(null));
|
||||
const last = options.actions.length - 1;
|
||||
return (
|
||||
<div className="confirm-backdrop" onClick={() => onSettle(false)}>
|
||||
<div className="confirm-backdrop" onClick={() => onSettle(null)}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="confirm-card"
|
||||
@@ -97,17 +151,26 @@ function Dialog({
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost"
|
||||
onClick={() => onSettle(false)}
|
||||
onClick={() => onSettle(null)}
|
||||
>
|
||||
{options.cancelLabel ?? 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-btn ${options.danger ? 'confirm-danger' : 'admin-btn-primary'}`}
|
||||
onClick={() => onSettle(true)}
|
||||
>
|
||||
{options.confirmLabel ?? 'Confirm'}
|
||||
</button>
|
||||
{options.actions.map((action, i) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
className={`admin-btn ${
|
||||
action.danger
|
||||
? 'confirm-danger'
|
||||
: i === last
|
||||
? 'admin-btn-primary'
|
||||
: 'admin-btn-ghost'
|
||||
}`}
|
||||
onClick={() => onSettle(action.id)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,16 @@ const GROUPS: { id: string; title: string; checks: string[] }[] = [
|
||||
{
|
||||
id: 'content',
|
||||
title: 'Content',
|
||||
checks: ['albums-shared', 'content-writable', 'alt-text', 'legal', 'contact', 'privacy'],
|
||||
checks: [
|
||||
'albums-shared',
|
||||
'content-pages',
|
||||
'content-writable',
|
||||
'alt-text',
|
||||
'legal',
|
||||
'contact',
|
||||
'privacy',
|
||||
'settings-values',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -65,9 +74,12 @@ const FIXES: Record<string, { label: string; href: string }> = {
|
||||
passwords: { label: 'Open settings', href: '/admin/settings/security' },
|
||||
'album-ids': { label: 'Open pages', href: '/admin/pages' },
|
||||
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
|
||||
'content-pages': { label: 'Open pages', href: '/admin/pages' },
|
||||
legal: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||
contact: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||
privacy: { label: 'Open settings', href: '/admin/settings/legal' },
|
||||
// The href is replaced by the finding's own settingsSection.
|
||||
'settings-values': { label: 'Open settings', href: '/admin/settings/general' },
|
||||
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
|
||||
'immich-api': {
|
||||
label: 'API key permissions',
|
||||
@@ -320,8 +332,12 @@ export default function DiagnosticsView() {
|
||||
}
|
||||
return null; // The photo list toggle is rendered separately.
|
||||
}
|
||||
const fix = FIXES[id];
|
||||
if (!fix) return null;
|
||||
const known = FIXES[id];
|
||||
if (!known) return null;
|
||||
// A finding that knows which settings section holds its fix goes there.
|
||||
const fix = f.settingsSection
|
||||
? { ...known, href: `/admin/settings/${f.settingsSection}` }
|
||||
: known;
|
||||
return fix.href.startsWith('/') ? (
|
||||
<Link href={fix.href} className="admin-btn admin-btn-sm">
|
||||
{fix.label}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
IconX,
|
||||
} from './Icons';
|
||||
import { BlockBadge } from './BlockBadge';
|
||||
import { InlineMarkdownField } from './InlineMarkdownField';
|
||||
|
||||
interface EssayBlockEditorProps {
|
||||
markdown: string;
|
||||
@@ -291,19 +292,17 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
|
||||
{/* 2. PARAGRAPH BLOCK
|
||||
|
||||
The textarea shows `html` raw, as the journal editor does. It used
|
||||
to strip the tags for display — `<strong>` to `**`, everything else
|
||||
to nothing — while `onChange` wrote the stripped text straight back
|
||||
into `html`. Italics, links, anything the markdown had produced was
|
||||
destroyed by the act of editing the paragraph, and the two editors
|
||||
disagreed about the same file. serializeJournalMarkdown() turns the
|
||||
HTML back into markdown on save and handles `<em>` too, so there
|
||||
was nothing to convert here in the first place. */}
|
||||
The field shows the markdown source and writes the rendered HTML
|
||||
back, as the journal editor does (InlineMarkdownField). An older
|
||||
version stripped the tags for display and wrote the stripped text
|
||||
straight back into `html`, destroying italics and links; the one
|
||||
after that showed the raw HTML. */}
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
<InlineMarkdownField
|
||||
multiline
|
||||
rows={3}
|
||||
value={block.html}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
html={block.html}
|
||||
onChange={(html) => handleUpdateBlock(idx, { ...block, html })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
||||
/>
|
||||
)}
|
||||
@@ -319,10 +318,11 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
paddingLeft: '10px',
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
<InlineMarkdownField
|
||||
multiline
|
||||
rows={2}
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
html={block.text}
|
||||
onChange={(text) => handleUpdateBlock(idx, { ...block, text })}
|
||||
placeholder="Quote text..."
|
||||
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
|
||||
/>
|
||||
@@ -411,11 +411,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
<InlineMarkdownField
|
||||
html={block.caption}
|
||||
onChange={(caption) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||
}
|
||||
placeholder="Photo caption (optional)"
|
||||
/>
|
||||
@@ -573,11 +572,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
<InlineMarkdownField
|
||||
html={block.caption}
|
||||
onChange={(caption) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||
}
|
||||
placeholder="Side-by-side caption (optional)"
|
||||
/>
|
||||
@@ -693,11 +691,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
<IconPlus size={12} /> Add photo
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
<InlineMarkdownField
|
||||
html={block.caption}
|
||||
onChange={(caption) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||
}
|
||||
placeholder="Grid caption (optional)"
|
||||
/>
|
||||
@@ -729,16 +726,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.value}
|
||||
<InlineMarkdownField
|
||||
html={item.value}
|
||||
placeholder="Value, e.g. 21 km"
|
||||
onChange={(e) =>
|
||||
onChange={(value) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
items: block.items.map((it, i) =>
|
||||
i === fIdx ? { ...it, value: e.target.value } : it,
|
||||
),
|
||||
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -828,13 +822,12 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
<option value="wide">Wide (one per row)</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
placeholder="Caption for the set (optional)"
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
<InlineMarkdownField
|
||||
html={block.caption}
|
||||
onChange={(caption) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||
}
|
||||
placeholder="Caption for the set (optional)"
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}>
|
||||
Expanded into photo blocks when the page renders — the album's order, a
|
||||
@@ -846,11 +839,10 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
{/* 8. MAP BLOCK */}
|
||||
{block.type === 'map' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
<InlineMarkdownField
|
||||
html={block.caption}
|
||||
onChange={(caption) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: caption || undefined })
|
||||
}
|
||||
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
||||
/>
|
||||
|
||||
63
app/admin/components/InlineMarkdownField.tsx
Normal file
63
app/admin/components/InlineMarkdownField.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||
import { inlineHtmlToMarkdown, renderInlineMarkdown } from '@/lib/journal';
|
||||
|
||||
type SharedProps = {
|
||||
/**
|
||||
* The block field as the parser stores it: HTML from renderInlineMarkdown
|
||||
* (a paragraph's `html`, a quote's `text`, a caption, a fact value).
|
||||
*/
|
||||
html: string | undefined;
|
||||
/** Receives the field as HTML again, rendered from what was typed. */
|
||||
onChange: (html: string) => void;
|
||||
};
|
||||
|
||||
type InputProps = SharedProps & {
|
||||
multiline?: false;
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'>;
|
||||
|
||||
type TextareaProps = SharedProps & {
|
||||
multiline: true;
|
||||
} & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'onChange'>;
|
||||
|
||||
/**
|
||||
* A text field for an inline-markdown block field (A-5).
|
||||
*
|
||||
* Blocks hold rendered HTML, since that is what the public page and the live
|
||||
* preview show. The editors used to put that HTML straight into the field: a
|
||||
* reloaded entry showed `<strong>` and `<a href="…">` in the textarea, text
|
||||
* typed into a tag was lost on save, and typed `**bold**` stayed literal in
|
||||
* the preview until a save re-parsed the file.
|
||||
*
|
||||
* The field shows the markdown source instead and hands every edit back
|
||||
* rendered, so the preview is right while typing and the serializer writes
|
||||
* the same markdown for an untouched block. What was typed is kept as typed:
|
||||
* deriving the field from the HTML on every keystroke would rewrite `__x__`
|
||||
* to `**x**`, or drop a half-typed link, under the cursor. The field re-reads
|
||||
* the HTML only when it changes from outside (markdown mode, a reorder, a
|
||||
* restore).
|
||||
*/
|
||||
export function InlineMarkdownField(props: InputProps | TextareaProps) {
|
||||
const html = props.html ?? '';
|
||||
const [text, setText] = useState(() => inlineHtmlToMarkdown(html));
|
||||
const [shownHtml, setShownHtml] = useState(html);
|
||||
if (html !== shownHtml) {
|
||||
setShownHtml(html);
|
||||
setText(inlineHtmlToMarkdown(html));
|
||||
}
|
||||
|
||||
const update = (next: string) => {
|
||||
const nextHtml = renderInlineMarkdown(next);
|
||||
setText(next);
|
||||
setShownHtml(nextHtml);
|
||||
props.onChange(nextHtml);
|
||||
};
|
||||
|
||||
if (props.multiline) {
|
||||
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
|
||||
return <textarea {...rest} value={text} onChange={(e) => update(e.target.value)} />;
|
||||
}
|
||||
const { html: _html, onChange: _onChange, multiline: _multiline, ...rest } = props;
|
||||
return <input type="text" {...rest} value={text} onChange={(e) => update(e.target.value)} />;
|
||||
}
|
||||
@@ -24,7 +24,16 @@ import SaveBar, { type SaveStatus } from './SaveBar';
|
||||
import AlbumDrawer from './page-builder/AlbumDrawer';
|
||||
import { SortableAlbumCard } from './page-builder/AlbumCard';
|
||||
import SubpageDrawer from './page-builder/SubpageDrawer';
|
||||
import { SortableHeroTile, SortableSubpageRow } from './page-builder/SortableTiles';
|
||||
import {
|
||||
OffMenuDropZone,
|
||||
SortableHeroTile,
|
||||
SortablePageRow,
|
||||
SortableSubpageRow,
|
||||
} from './page-builder/SortableTiles';
|
||||
import PagePanel from './page-builder/PagePanel';
|
||||
import { isValidSlug } from '@/lib/journal';
|
||||
import NewPageDialog from './page-builder/NewPageDialog';
|
||||
import type { PageSummary, SlugTakenBy } from '@/lib/pages';
|
||||
import { findAlbumAddress } from './page-builder/findAlbumAddress';
|
||||
import { parseGalleryYaml, serializeGallery, type GalleryState } from './page-builder/galleryYaml';
|
||||
import * as ops from './page-builder/galleryOps';
|
||||
@@ -41,11 +50,22 @@ import {
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||
import { useDraft } from './useDraft';
|
||||
import { useLatest } from './useLatest';
|
||||
import { useVersionedSave } from './useVersionedSave';
|
||||
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
|
||||
import DraftNotice from './DraftNotice';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import { IconCamera, IconHome, IconPlus, IconSearch } from './Icons';
|
||||
import { useNotify } from './Notifications';
|
||||
|
||||
/** Drop target id of the "Not in menu" group. */
|
||||
const OFF_MENU_ID = 'offmenu-zone';
|
||||
|
||||
/** Sortable id of a menu row. Subpages keep their old `subpage-<index>` id. */
|
||||
function menuRowId(item: ops.MenuItem): string {
|
||||
return item.kind === 'subpage' ? `subpage-${item.index}` : `page-${item.slug}`;
|
||||
}
|
||||
|
||||
export default function PageBuilder() {
|
||||
const [gallery, setGallery] = useState<GalleryState>({ hero: [], albums: [], subpages: [] });
|
||||
const notify = useNotify();
|
||||
@@ -79,6 +99,21 @@ export default function PageBuilder() {
|
||||
/** Album a `?album=` link pointed at, marked in its subpage sheet for a moment. */
|
||||
const [linkedAlbumId, setLinkedAlbumId] = useState<string | null>(null);
|
||||
|
||||
// ── Content pages (#722) ──
|
||||
/** Every page file; the menu itself lives in `gallery.pageRefs`. */
|
||||
const [pages, setPages] = useState<PageSummary[]>([]);
|
||||
const [takenSlugs, setTakenSlugs] = useState<SlugTakenBy>({
|
||||
subpages: [],
|
||||
albums: [],
|
||||
journal: [],
|
||||
});
|
||||
/** The page shown in the panel; exclusive with `expandedSubpage`. */
|
||||
const [selectedPage, setSelectedPage] = useState<string | null>(null);
|
||||
const [showNewPage, setShowNewPage] = useState(false);
|
||||
const [creatingPage, setCreatingPage] = useState(false);
|
||||
/** The edit panel beside (or, on narrow screens, below) the structure list. */
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep the builder still behind either drawer. One combined lock rather than
|
||||
// one per drawer: the album drawer opens from inside the subpage drawer, and
|
||||
// a single condition avoids two locks racing over the same inline style.
|
||||
@@ -121,7 +156,12 @@ export default function PageBuilder() {
|
||||
// Unsaved edits survive leaving the builder — a tab link, back, Reload,
|
||||
// Logout (#592). `serverState` is what a discard goes back to.
|
||||
const draft = useDraft<GalleryState>('page-builder', gallery, dirty);
|
||||
/** The tree as last rendered: tells a finished save whether editing went on meanwhile. */
|
||||
const latestGallery = useLatest(gallery);
|
||||
const serverState = useRef<GalleryState | null>(null);
|
||||
/** gallery.yaml as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||
const versionRef = useRef<string | null>(null);
|
||||
const versionedSave = useVersionedSave();
|
||||
|
||||
// A subpage opened from a `?album=` link can hold dozens of albums: bring the
|
||||
// linked one into view, and let the mark fade after a moment. The mark itself
|
||||
@@ -147,9 +187,10 @@ export default function PageBuilder() {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const [galleryRes, albumsRes] = await Promise.all([
|
||||
const [galleryRes, albumsRes, pagesRes] = await Promise.all([
|
||||
fetch('/api/admin/gallery'),
|
||||
fetch('/api/admin/albums'),
|
||||
fetch('/api/admin/pages'),
|
||||
]);
|
||||
|
||||
if (!galleryRes.ok) {
|
||||
@@ -160,7 +201,8 @@ export default function PageBuilder() {
|
||||
);
|
||||
}
|
||||
|
||||
const { gallery: raw } = await galleryRes.json();
|
||||
const { gallery: raw, version } = await galleryRes.json();
|
||||
versionRef.current = typeof version === 'string' ? version : null;
|
||||
const parsed = parseGalleryYaml(raw);
|
||||
serverState.current = parsed;
|
||||
const restored = draft.load(JSON.stringify(parsed));
|
||||
@@ -176,6 +218,15 @@ export default function PageBuilder() {
|
||||
const { albums } = await albumsRes.json();
|
||||
setImmichAlbums(albums);
|
||||
}
|
||||
|
||||
// Pages are survivable the same way: without the list the menu still
|
||||
// shows their references, and saving keeps them.
|
||||
if (pagesRes.ok) {
|
||||
const data = (await pagesRes.json()) as { pages: PageSummary[]; taken: SlugTakenBy };
|
||||
setPages(data.pages);
|
||||
setTakenSlugs(data.taken);
|
||||
}
|
||||
openPageFromLink();
|
||||
} catch (err) {
|
||||
console.error('Failed to load admin data:', err);
|
||||
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
|
||||
@@ -213,6 +264,21 @@ export default function PageBuilder() {
|
||||
}
|
||||
}
|
||||
|
||||
/** `?page=<slug>` selects a page — the page editor's back link uses it. One-shot. */
|
||||
function openPageFromLink() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const slug = params.get('page');
|
||||
if (slug === null) return;
|
||||
params.delete('page');
|
||||
const query = params.toString();
|
||||
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
|
||||
// The slug comes from the address bar and becomes the page's live link, so
|
||||
// only a real page slug may select a page — `//host` would leave the site.
|
||||
if (!isValidSlug(slug)) return;
|
||||
setExpandedSubpage(null);
|
||||
setSelectedPage(slug);
|
||||
}
|
||||
|
||||
function discardDraft() {
|
||||
draft.discard();
|
||||
if (serverState.current) setGallery(serverState.current);
|
||||
@@ -240,17 +306,24 @@ export default function PageBuilder() {
|
||||
setSaving(true);
|
||||
setSaveStatus(null);
|
||||
|
||||
const yamlData = serializeGallery(gallery);
|
||||
const sent = gallery;
|
||||
const yamlData = serializeGallery(sent);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/gallery', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gallery: yamlData }),
|
||||
});
|
||||
const result = await versionedSave(
|
||||
'/api/admin/gallery',
|
||||
{ gallery: yamlData },
|
||||
versionRef,
|
||||
'The page structure (gallery.yaml)',
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (result.kind === 'reload') {
|
||||
draft.discard();
|
||||
await loadData();
|
||||
} else if (result.kind === 'keep') {
|
||||
setSaveStatus({ kind: 'error', message: 'Not saved — gallery.yaml changed elsewhere.' });
|
||||
} else if (result.kind === 'saved') {
|
||||
const data = result.data as { gallery?: unknown; message?: string };
|
||||
// Fingerprint what the next load will see, the way it will see it: the
|
||||
// file as written, read back through the same parser. The editor's own
|
||||
// state can differ in shape (an `undefined` here, a default there) and
|
||||
@@ -262,15 +335,22 @@ export default function PageBuilder() {
|
||||
const written = (data.gallery ?? yamlData) as Record<string, unknown>;
|
||||
const asLoaded = parseGalleryYaml(JSON.parse(JSON.stringify(written)));
|
||||
serverState.current = asLoaded;
|
||||
draft.saved(JSON.stringify(asLoaded));
|
||||
// Show the hashes, so a new password reads "Protected" right away.
|
||||
if (JSON.stringify(written) !== JSON.stringify(yamlData)) setGallery(asLoaded);
|
||||
setDirty(false);
|
||||
// Edited while the request was out: those edits are not saved yet, so
|
||||
// they stay dirty and are not replaced by the file as written.
|
||||
const editedMeanwhile = latestGallery.current !== sent;
|
||||
draft.saved(JSON.stringify(asLoaded), editedMeanwhile);
|
||||
if (!editedMeanwhile) {
|
||||
// Show the hashes, so a new password reads "Protected" right away.
|
||||
if (JSON.stringify(written) !== JSON.stringify(yamlData)) setGallery(asLoaded);
|
||||
setDirty(false);
|
||||
}
|
||||
setSaveStatus({ kind: 'success', message: data.message || 'Saved successfully!' });
|
||||
setTimeout(() => setSaveStatus(null), 5000);
|
||||
} else if (!reportIfSessionExpired(res)) {
|
||||
const err = await res.json();
|
||||
setSaveStatus({ kind: 'error', message: `Error: ${err.error}` });
|
||||
} else if (!reportIfSessionExpired(result.res)) {
|
||||
setSaveStatus({
|
||||
kind: 'error',
|
||||
message: `Error: ${result.data?.error ?? `HTTP ${result.res.status}`}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setSaveStatus({ kind: 'error', message: 'Error: Failed to save' });
|
||||
@@ -286,8 +366,8 @@ export default function PageBuilder() {
|
||||
}
|
||||
|
||||
// ── Pickers ──────────────────────────────────────────────────
|
||||
function handleHeroSelect(assetId: string) {
|
||||
edit((g) => ops.addHero(g, assetId));
|
||||
function handleHeroSelectMany(assetIds: string[]) {
|
||||
edit((g) => ops.addHeroes(g, assetIds));
|
||||
setHeroPickerTarget(null);
|
||||
}
|
||||
|
||||
@@ -322,17 +402,158 @@ export default function PageBuilder() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubpageDragEnd(event: DragEndEvent) {
|
||||
/**
|
||||
* The menu list holds subpages and pages in one order (#722). A page can
|
||||
* also be dragged into or out of "Not in menu", which toggles its "Show in
|
||||
* menu" switch.
|
||||
*/
|
||||
function handleMenuDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const activeId = String(active.id);
|
||||
const overId = String(over.id);
|
||||
const items = ops.menuItems(gallery);
|
||||
const rowIndex = (id: string) => items.findIndex((item) => menuRowId(item) === id);
|
||||
const overOffMenu = overId === OFF_MENU_ID || overId.startsWith('off-');
|
||||
|
||||
const oldIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === active.id);
|
||||
const newIndex = gallery.subpages.findIndex((_, i) => `subpage-${i}` === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
edit((g) => ops.moveSubpage(g, oldIndex, newIndex));
|
||||
setExpandedSubpage(ops.followMovedIndex(expandedSubpage, oldIndex, newIndex));
|
||||
if (activeId.startsWith('off-')) {
|
||||
if (overOffMenu) return;
|
||||
const at = rowIndex(overId);
|
||||
if (at === -1) return;
|
||||
const slug = activeId.slice('off-'.length);
|
||||
edit((g) => ops.setPageInMenu(g, slug, true, at));
|
||||
return;
|
||||
}
|
||||
|
||||
const from = rowIndex(activeId);
|
||||
if (from === -1) return;
|
||||
if (overOffMenu) {
|
||||
const item = items[from];
|
||||
if (item.kind === 'page') edit((g) => ops.setPageInMenu(g, item.slug, false));
|
||||
return;
|
||||
}
|
||||
const to = rowIndex(overId);
|
||||
if (to === -1) return;
|
||||
setExpandedSubpage(ops.followMenuMove(gallery, expandedSubpage, from, to));
|
||||
edit((g) => ops.moveMenuItem(g, from, to));
|
||||
}
|
||||
|
||||
// ── Content pages ────────────────────────────────────────────
|
||||
function selectPage(slug: string) {
|
||||
setExpandedSubpage(null);
|
||||
setLinkedAlbumId(null);
|
||||
setSelectedPage(slug);
|
||||
revealPanel();
|
||||
}
|
||||
|
||||
function selectSubpage(index: number) {
|
||||
setSelectedPage(null);
|
||||
setExpandedSubpage(index);
|
||||
revealPanel();
|
||||
}
|
||||
|
||||
function selectOverview(which: 'hero' | 'albums') {
|
||||
setExpandedSubpage(null);
|
||||
setSelectedPage(null);
|
||||
setOverview(which);
|
||||
revealPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Below 1100px the panel stacks under the structure list, so on a phone a
|
||||
* tapped row changed something a screen further down and looked like it
|
||||
* did nothing. Scroll the panel up once it has rendered.
|
||||
*/
|
||||
function revealPanel() {
|
||||
if (!window.matchMedia?.('(max-width: 1100px)').matches) return;
|
||||
requestAnimationFrame(() => {
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
panelRef.current?.scrollIntoView({ block: 'start', behavior: reduce ? 'auto' : 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
const togglePageMenu = (slug: string) =>
|
||||
edit((g) => ops.setPageInMenu(g, slug, !ops.isPageInMenu(g, slug)));
|
||||
|
||||
/**
|
||||
* gallery.yaml was changed on the server by a page rename or delete. The
|
||||
* same change goes into the builder's state and into what it considers
|
||||
* saved, so neither a pending edit nor the draft check trips over it.
|
||||
*/
|
||||
function applyServerMenuChange(
|
||||
op: (g: GalleryState) => GalleryState,
|
||||
change?: GalleryVersionChange | null,
|
||||
) {
|
||||
setGallery(op);
|
||||
// Follow the rewrite only when it was the sole change since this editor
|
||||
// loaded; otherwise the old version stays and the next save asks (#601).
|
||||
if (change && versionRef.current === change.from) versionRef.current = change.to;
|
||||
if (serverState.current) {
|
||||
serverState.current = op(serverState.current);
|
||||
draft.saved(JSON.stringify(serverState.current));
|
||||
}
|
||||
}
|
||||
|
||||
async function createPage(input: { title: string; slug: string; showInMenu: boolean }) {
|
||||
setCreatingPage(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/pages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: input.title, slug: input.slug }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
if (!reportIfSessionExpired(res))
|
||||
notify('error', data?.error || 'Failed to create the page');
|
||||
return;
|
||||
}
|
||||
const slug: string = data.page.slug;
|
||||
setPages((prev) => [
|
||||
...prev.filter((p) => p.slug !== slug),
|
||||
{ slug, frontmatter: { title: input.title, draft: true } },
|
||||
]);
|
||||
if (input.showInMenu) edit((g) => ops.setPageInMenu(g, slug, true));
|
||||
setShowNewPage(false);
|
||||
selectPage(slug);
|
||||
notify(
|
||||
'success',
|
||||
input.showInMenu
|
||||
? 'Page created as a draft. Save Changes to put it in the menu.'
|
||||
: 'Page created as a draft.',
|
||||
);
|
||||
} catch {
|
||||
notify('error', 'Could not create the page. Check the connection and try again.');
|
||||
} finally {
|
||||
setCreatingPage(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSaved(
|
||||
oldSlug: string,
|
||||
page: PageSummary,
|
||||
menuRenamed: boolean,
|
||||
galleryVersion?: GalleryVersionChange | null,
|
||||
) {
|
||||
setPages((prev) => [...prev.filter((p) => p.slug !== oldSlug), page]);
|
||||
if (page.slug !== oldSlug) {
|
||||
const rename = (g: GalleryState) => ops.renamePageRef(g, oldSlug, page.slug);
|
||||
if (menuRenamed) applyServerMenuChange(rename, galleryVersion);
|
||||
else setGallery(rename);
|
||||
setSelectedPage(page.slug);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageDeleted(
|
||||
slug: string,
|
||||
removedFromMenu: boolean,
|
||||
galleryVersion?: GalleryVersionChange | null,
|
||||
) {
|
||||
setPages((prev) => prev.filter((p) => p.slug !== slug));
|
||||
const remove = (g: GalleryState) => ops.setPageInMenu(g, slug, false);
|
||||
if (removedFromMenu) applyServerMenuChange(remove, galleryVersion);
|
||||
else setGallery(remove);
|
||||
setSelectedPage(null);
|
||||
}
|
||||
|
||||
function handleSubpageAlbumDragEnd(spIndex: number) {
|
||||
@@ -510,6 +731,16 @@ export default function PageBuilder() {
|
||||
);
|
||||
});
|
||||
|
||||
// The menu: subpages and pages in saved order (#722). Search filters it.
|
||||
const pageBySlug = new Map(pages.map((p) => [p.slug, p]));
|
||||
const pageTitle = (slug: string) => pageBySlug.get(slug)?.frontmatter.title || slug;
|
||||
const query = searchQuery.toLowerCase();
|
||||
const pageMatches = (slug: string) =>
|
||||
!searchQuery || slug.includes(query) || pageTitle(slug).toLowerCase().includes(query);
|
||||
const offMenuPages = pages
|
||||
.filter((p) => !ops.isPageInMenu(gallery, p.slug) && pageMatches(p.slug))
|
||||
.sort((a, b) => pageTitle(a.slug).localeCompare(pageTitle(b.slug)));
|
||||
|
||||
// Filter subpages
|
||||
const filteredSubpages = gallery.subpages
|
||||
.map((sp, index) => ({ sp, index }))
|
||||
@@ -537,6 +768,12 @@ export default function PageBuilder() {
|
||||
|
||||
return hasMatchingAlbum;
|
||||
});
|
||||
const shownSubpages = new Set(filteredSubpages.map(({ index }) => index));
|
||||
const menuRows = ops
|
||||
.menuItems(gallery)
|
||||
.filter((item) =>
|
||||
item.kind === 'subpage' ? shownSubpages.has(item.index) : pageMatches(item.slug),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-builder">
|
||||
@@ -593,10 +830,7 @@ export default function PageBuilder() {
|
||||
<button
|
||||
type="button"
|
||||
className={`pb-row-main pb-row-solo ${expandedSubpage === null && overview === 'hero' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setExpandedSubpage(null);
|
||||
setOverview('hero');
|
||||
}}
|
||||
onClick={() => selectOverview('hero')}
|
||||
>
|
||||
<span className="pb-row-thumb">
|
||||
{gallery.hero[0] ? (
|
||||
@@ -610,49 +844,102 @@ export default function PageBuilder() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pb-group">
|
||||
<div className="pb-group-head">
|
||||
<span>Subpages</span>
|
||||
<span>
|
||||
{gallery.subpages.filter((sp) => sp.enabled !== false).length} of{' '}
|
||||
{gallery.subpages.length} live
|
||||
</span>
|
||||
</div>
|
||||
{gallery.subpages.length > 0 && filteredSubpages.length === 0 && (
|
||||
<p className="empty-hint">No matching subpages.</p>
|
||||
)}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleSubpageDragEnd}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleMenuDragEnd}
|
||||
>
|
||||
<div className="pb-group">
|
||||
<div className="pb-group-head">
|
||||
<span>Menu</span>
|
||||
<span>
|
||||
{gallery.subpages.filter((sp) => sp.enabled !== false).length} of{' '}
|
||||
{gallery.subpages.length} subpages live
|
||||
</span>
|
||||
</div>
|
||||
{(gallery.subpages.length > 0 || (gallery.pageRefs ?? []).length > 0) &&
|
||||
menuRows.length === 0 && <p className="empty-hint">No matching entries.</p>}
|
||||
<SortableContext
|
||||
items={filteredSubpages.map(({ index }) => `subpage-${index}`)}
|
||||
items={menuRows.map(menuRowId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filteredSubpages.map(({ sp, index }) => (
|
||||
<SortableSubpageRow
|
||||
key={`subpage-${index}`}
|
||||
sp={sp}
|
||||
spIndex={index}
|
||||
isActive={expandedSubpage === index}
|
||||
onClick={() => setExpandedSubpage(index)}
|
||||
getFirstThumb={getFirstSubpageThumb}
|
||||
/>
|
||||
))}
|
||||
{menuRows.map((item) => {
|
||||
if (item.kind === 'subpage') {
|
||||
const sp = gallery.subpages[item.index];
|
||||
return (
|
||||
<SortableSubpageRow
|
||||
key={menuRowId(item)}
|
||||
sp={sp}
|
||||
spIndex={item.index}
|
||||
isActive={expandedSubpage === item.index}
|
||||
onClick={() => selectSubpage(item.index)}
|
||||
getFirstThumb={getFirstSubpageThumb}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const page = pageBySlug.get(item.slug);
|
||||
return (
|
||||
<SortablePageRow
|
||||
key={menuRowId(item)}
|
||||
id={menuRowId(item)}
|
||||
title={pageTitle(item.slug)}
|
||||
isActive={selectedPage === item.slug}
|
||||
draft={page?.frontmatter.draft}
|
||||
hasPassword={!!page?.frontmatter.password}
|
||||
// Only once the list has loaded: an empty list is not proof.
|
||||
missing={pages.length > 0 && !page}
|
||||
onClick={() => selectPage(item.slug)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button
|
||||
type="button"
|
||||
className="pb-add"
|
||||
onClick={() => {
|
||||
addSubpage();
|
||||
setExpandedSubpage(gallery.subpages.length);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={13} /> New subpage
|
||||
</button>
|
||||
</div>
|
||||
<div className="pb-add-row">
|
||||
<button
|
||||
type="button"
|
||||
className="pb-add"
|
||||
onClick={() => {
|
||||
addSubpage();
|
||||
selectSubpage(gallery.subpages.length);
|
||||
}}
|
||||
>
|
||||
<IconPlus size={13} /> New subpage
|
||||
</button>
|
||||
<button type="button" className="pb-add" onClick={() => setShowNewPage(true)}>
|
||||
<IconPlus size={13} /> New page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pb-group">
|
||||
<div className="pb-group-head">
|
||||
<span>Not in menu</span>
|
||||
<span>{offMenuPages.length}</span>
|
||||
</div>
|
||||
<OffMenuDropZone id={OFF_MENU_ID}>
|
||||
<SortableContext
|
||||
items={offMenuPages.map((p) => `off-${p.slug}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{offMenuPages.map((p) => (
|
||||
<SortablePageRow
|
||||
key={`off-${p.slug}`}
|
||||
id={`off-${p.slug}`}
|
||||
title={pageTitle(p.slug)}
|
||||
isActive={selectedPage === p.slug}
|
||||
draft={p.frontmatter.draft}
|
||||
hasPassword={!!p.frontmatter.password}
|
||||
onClick={() => selectPage(p.slug)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{offMenuPages.length === 0 && (
|
||||
<p className="empty-hint">
|
||||
Pages reachable only by their link. Drag a page here to take it out of the menu.
|
||||
</p>
|
||||
)}
|
||||
</OffMenuDropZone>
|
||||
</div>
|
||||
</DndContext>
|
||||
|
||||
<div className="pb-group">
|
||||
<div className="pb-group-head">
|
||||
@@ -661,10 +948,7 @@ export default function PageBuilder() {
|
||||
<button
|
||||
type="button"
|
||||
className={`pb-row-main pb-row-solo ${expandedSubpage === null && overview === 'albums' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setExpandedSubpage(null);
|
||||
setOverview('albums');
|
||||
}}
|
||||
onClick={() => selectOverview('albums')}
|
||||
>
|
||||
<span className="pb-row-thumb">
|
||||
<IconCamera size={14} />
|
||||
@@ -676,8 +960,19 @@ export default function PageBuilder() {
|
||||
</aside>
|
||||
|
||||
{/* The selected entry, edited in place instead of in an overlay. */}
|
||||
<div className="pb-panel">
|
||||
{expandedSubpage !== null && gallery.subpages[expandedSubpage] ? (
|
||||
<div className="pb-panel" ref={panelRef}>
|
||||
{selectedPage !== null ? (
|
||||
<PagePanel
|
||||
key={selectedPage}
|
||||
slug={selectedPage}
|
||||
inMenu={ops.isPageInMenu(gallery, selectedPage)}
|
||||
onToggleMenu={() => togglePageMenu(selectedPage)}
|
||||
taken={takenSlugs}
|
||||
otherPageSlugs={pages.map((p) => p.slug).filter((s) => s !== selectedPage)}
|
||||
onSaved={handlePageSaved}
|
||||
onDeleted={handlePageDeleted}
|
||||
/>
|
||||
) : expandedSubpage !== null && gallery.subpages[expandedSubpage] ? (
|
||||
<>
|
||||
<SubpageDrawer
|
||||
sp={gallery.subpages[expandedSubpage]}
|
||||
@@ -782,9 +1077,9 @@ export default function PageBuilder() {
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() =>
|
||||
setHeroPickerTarget({
|
||||
onSelect: handleHeroSelect,
|
||||
onSelectMany: handleHeroSelectMany,
|
||||
currentAssetIds: gallery.hero,
|
||||
title: 'Pick Hero Image for Homepage',
|
||||
title: 'Pick Hero Images for Homepage',
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -824,6 +1119,16 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewPage && (
|
||||
<NewPageDialog
|
||||
taken={takenSlugs}
|
||||
existingPageSlugs={pages.map((p) => p.slug)}
|
||||
creating={creatingPage}
|
||||
onCreate={createPage}
|
||||
onClose={() => setShowNewPage(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Album Picker Modal */}
|
||||
{pickerTarget && (
|
||||
<AlbumPicker
|
||||
@@ -855,6 +1160,7 @@ export default function PageBuilder() {
|
||||
<AssetPicker
|
||||
albumId={heroPickerTarget.albumId}
|
||||
onSelect={heroPickerTarget.onSelect}
|
||||
onSelectMany={heroPickerTarget.onSelectMany}
|
||||
onClose={() => setHeroPickerTarget(null)}
|
||||
currentAssetIds={heroPickerTarget.currentAssetIds}
|
||||
title={heroPickerTarget.title}
|
||||
|
||||
@@ -11,10 +11,15 @@
|
||||
* key in settings.yaml.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Icons from './Icons';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||
import { useContentRestored } from './contentRestored';
|
||||
import { useVersionedSave } from './useVersionedSave';
|
||||
import { useLatest } from './useLatest';
|
||||
import { useDraft } from './useDraft';
|
||||
import DraftNotice from './DraftNotice';
|
||||
import type { ProcessingFact } from '@/lib/privacy';
|
||||
|
||||
interface PrivacyData {
|
||||
@@ -22,6 +27,7 @@ interface PrivacyData {
|
||||
enabled: boolean;
|
||||
facts: ProcessingFact[];
|
||||
starter: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
type Status = { kind: 'success' | 'error'; message: string } | null;
|
||||
@@ -35,44 +41,80 @@ export default function PrivacyEditor() {
|
||||
const [status, setStatus] = useState<Status>(null);
|
||||
|
||||
useUnsavedGuard(dirty);
|
||||
// The editor lives in the Legal section and unmounts when another section
|
||||
// opens; without a draft an unsaved policy was simply gone (#554, #592).
|
||||
const draft = useDraft<string>('privacy', body, dirty);
|
||||
const loadDraft = draft.load;
|
||||
|
||||
/** privacy.md as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||
const versionRef = useRef<string | null>(null);
|
||||
const versionedSave = useVersionedSave();
|
||||
/** The text as last rendered: tells a finished save whether typing went on meanwhile. */
|
||||
const latestBody = useLatest(body);
|
||||
const dirtyRef = useRef(dirty);
|
||||
useEffect(() => {
|
||||
dirtyRef.current = dirty;
|
||||
});
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/privacy');
|
||||
if (!res.ok) {
|
||||
reportIfSessionExpired(res);
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const d = (await res.json()) as PrivacyData;
|
||||
versionRef.current = d.version ?? null;
|
||||
setData(d);
|
||||
const restored = loadDraft(d.body);
|
||||
setBody(restored ?? d.body);
|
||||
setDirty(restored !== null);
|
||||
} catch (err) {
|
||||
setLoadError(err instanceof Error ? err.message : 'Could not load.');
|
||||
}
|
||||
}, [loadDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
fetch('/api/admin/privacy')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
reportIfSessionExpired(res);
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as PrivacyData;
|
||||
})
|
||||
.then((d) => {
|
||||
if (!live) return;
|
||||
setData(d);
|
||||
setBody(d.body);
|
||||
})
|
||||
.catch((err: Error) => live && setLoadError(err.message));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// A restored privacy.md replaces what this editor shows — unless there are
|
||||
// unsaved edits, which stay; their save then meets the conflict prompt.
|
||||
useContentRestored(({ target }) => {
|
||||
if (target === 'privacy' && !dirtyRef.current) void load();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
const sent = body;
|
||||
try {
|
||||
const res = await fetch('/api/admin/privacy', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (!reportIfSessionExpired(res)) {
|
||||
setStatus({ kind: 'error', message: `Could not save (HTTP ${res.status}).` });
|
||||
const result = await versionedSave(
|
||||
'/api/admin/privacy',
|
||||
{ body: sent },
|
||||
versionRef,
|
||||
'The privacy policy',
|
||||
);
|
||||
if (result.kind === 'reload') {
|
||||
draft.discard();
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'keep') {
|
||||
setStatus({ kind: 'error', message: 'Not saved — the policy changed elsewhere.' });
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'failed') {
|
||||
if (!reportIfSessionExpired(result.res)) {
|
||||
setStatus({ kind: 'error', message: `Could not save (HTTP ${result.res.status}).` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDirty(false);
|
||||
// Typed while the request was out: that part is not saved yet.
|
||||
const editedMeanwhile = latestBody.current !== sent;
|
||||
setData((d) => (d ? { ...d, body: sent } : d));
|
||||
draft.saved(sent, editedMeanwhile);
|
||||
if (!editedMeanwhile) setDirty(false);
|
||||
setStatus({ kind: 'success', message: 'Privacy policy saved.' });
|
||||
} catch {
|
||||
setStatus({ kind: 'error', message: 'Could not reach the server.' });
|
||||
@@ -99,6 +141,24 @@ export default function PrivacyEditor() {
|
||||
|
||||
return (
|
||||
<div className="privacy-editor">
|
||||
<DraftNotice
|
||||
status={draft.status}
|
||||
subject="privacy policy"
|
||||
onDiscard={() => {
|
||||
draft.discard();
|
||||
setBody(data.body);
|
||||
setDirty(false);
|
||||
setStatus(null);
|
||||
}}
|
||||
onRestore={() => {
|
||||
const value = draft.takeConflicting();
|
||||
if (value === null) return;
|
||||
setBody(value);
|
||||
setDirty(true);
|
||||
}}
|
||||
onDismiss={draft.dismiss}
|
||||
/>
|
||||
|
||||
<div className="privacy-editor__facts">
|
||||
<h4>What this site processes</h4>
|
||||
<p className="admin-field-hint">
|
||||
|
||||
468
app/admin/components/ProofingView.tsx
Normal file
468
app/admin/components/ProofingView.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Admin → Proofing: client links, their selections and the export.
|
||||
*
|
||||
* One card per link. Creating a link picks a client name and an album — any
|
||||
* Immich album, published or not — plus optional expiry and download rights.
|
||||
* A card expands into the selected photos and the export buttons.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import PageHeader from './PageHeader';
|
||||
import * as Icons from './Icons';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import type { AdminProofPick, AdminProofSession } from '@/lib/admin/proofing-view';
|
||||
|
||||
interface AlbumOption {
|
||||
id: string;
|
||||
albumName: string;
|
||||
assetCount: number;
|
||||
}
|
||||
|
||||
type Download = AdminProofSession['download'];
|
||||
|
||||
const STATE_LABEL: Record<AdminProofSession['state'], string> = {
|
||||
open: 'Open',
|
||||
submitted: 'Submitted',
|
||||
expired: 'Expired',
|
||||
};
|
||||
|
||||
const DOWNLOAD_LABEL: Record<Download, string> = {
|
||||
none: 'No downloads',
|
||||
selection: 'Selection as ZIP',
|
||||
album: 'Whole album as ZIP',
|
||||
};
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso);
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (reportIfSessionExpired(res)) throw new Error('Session expired');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function CreateForm({ onCreated }: { onCreated: () => void }) {
|
||||
const [albums, setAlbums] = useState<AlbumOption[] | null>(null);
|
||||
const [clientName, setClientName] = useState('');
|
||||
const [albumId, setAlbumId] = useState('');
|
||||
const [expiresOn, setExpiresOn] = useState('');
|
||||
const [download, setDownload] = useState<Download>('none');
|
||||
const [limit, setLimit] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api<{ albums: AlbumOption[] }>('/api/admin/albums')
|
||||
.then((data) =>
|
||||
setAlbums([...data.albums].sort((a, b) => a.albumName.localeCompare(b.albumName))),
|
||||
)
|
||||
.catch((err: Error) => setError(err.message));
|
||||
}, []);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api('/api/admin/proofing', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
clientName,
|
||||
albumId,
|
||||
expiresOn: expiresOn || null,
|
||||
download,
|
||||
downloadLimit: download !== 'none' && limit ? Number(limit) : null,
|
||||
}),
|
||||
});
|
||||
setClientName('');
|
||||
setAlbumId('');
|
||||
setExpiresOn('');
|
||||
setDownload('none');
|
||||
setLimit('');
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="analytics-panel proofing-create" onSubmit={submit}>
|
||||
<div className="analytics-section-title">
|
||||
<h3>
|
||||
<Icons.IconPlus size={16} /> New client link
|
||||
</h3>
|
||||
</div>
|
||||
<div className="proofing-create-grid">
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-client">Client</label>
|
||||
<input
|
||||
id="proof-client"
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
placeholder="Anna & Ben"
|
||||
maxLength={120}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-album">Album</label>
|
||||
<select
|
||||
id="proof-album"
|
||||
value={albumId}
|
||||
onChange={(e) => setAlbumId(e.target.value)}
|
||||
required
|
||||
disabled={!albums}
|
||||
>
|
||||
<option value="">{albums ? 'Choose an Immich album…' : 'Loading albums…'}</option>
|
||||
{albums?.map((album) => (
|
||||
<option key={album.id} value={album.id}>
|
||||
{album.albumName} ({album.assetCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-expires">Valid until (optional)</label>
|
||||
<input
|
||||
id="proof-expires"
|
||||
type="date"
|
||||
value={expiresOn}
|
||||
onChange={(e) => setExpiresOn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-download">Downloads</label>
|
||||
<select
|
||||
id="proof-download"
|
||||
value={download}
|
||||
onChange={(e) => setDownload(e.target.value as Download)}
|
||||
>
|
||||
{(Object.keys(DOWNLOAD_LABEL) as Download[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{DOWNLOAD_LABEL[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{download !== 'none' && (
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-limit">Download limit (optional)</label>
|
||||
<input
|
||||
id="proof-limit"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
placeholder="Unlimited"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={busy || !clientName.trim() || !albumId}
|
||||
>
|
||||
<Icons.IconLink size={14} /> {busy ? 'Creating…' : 'Create link'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionCard({
|
||||
session,
|
||||
expanded,
|
||||
onToggle,
|
||||
onChanged,
|
||||
}: {
|
||||
session: AdminProofSession;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [picks, setPicks] = useState<AdminProofPick[] | null>(null);
|
||||
const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
api<{ picks: AdminProofPick[] }>(`/api/admin/proofing/${session.id}`)
|
||||
.then((data) => setPicks(data.picks))
|
||||
.catch((err: Error) => setError(err.message));
|
||||
}, [expanded, session.id, session.selected, session.submittedAt]);
|
||||
|
||||
const copy = (kind: 'link' | 'lightroom', text: string) => {
|
||||
if (!navigator.clipboard) {
|
||||
window.prompt('Copy:', text);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
setCopied(kind);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
},
|
||||
() => window.prompt('Copy:', text),
|
||||
);
|
||||
};
|
||||
|
||||
const patch = async (body: Record<string, unknown>) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api(`/api/admin/proofing/${session.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete the link for ${session.clientName}? It stops working at once.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/admin/proofing/${session.id}`, { method: 'DELETE' });
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLightroom = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/proofing/${session.id}/export?format=lightroom`);
|
||||
if (reportIfSessionExpired(res)) return;
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
copy('lightroom', await res.text());
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadsText =
|
||||
session.download === 'none'
|
||||
? DOWNLOAD_LABEL.none
|
||||
: `${DOWNLOAD_LABEL[session.download]} · ${session.downloadsUsed}${
|
||||
session.downloadLimit !== null ? ` / ${session.downloadLimit}` : ''
|
||||
} used`;
|
||||
|
||||
return (
|
||||
<div className={`analytics-panel proofing-card proofing-card--${session.state}`}>
|
||||
<div className="proofing-card-head">
|
||||
<div>
|
||||
<h3 className="proofing-card-title">
|
||||
{session.clientName}
|
||||
<span className={`proofing-badge proofing-badge--${session.state}`}>
|
||||
{STATE_LABEL[session.state]}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="analytics-subtitle">
|
||||
{session.albumName ?? 'Album not found in Immich'} · {session.selected}
|
||||
{session.albumAssetCount !== null ? ` of ${session.albumAssetCount}` : ''} selected ·
|
||||
created {formatDate(session.createdAt)}
|
||||
{session.expiresOn ? ` · valid until ${formatDate(session.expiresOn)}` : ''}
|
||||
</p>
|
||||
<p className="analytics-subtitle">
|
||||
{downloadsText}
|
||||
{session.submittedAt ? ` · submitted ${formatDate(session.submittedAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="proofing-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
onClick={() => copy('link', linkUrl)}
|
||||
>
|
||||
{copied === 'link' ? <Icons.IconCheck size={14} /> : <Icons.IconCopy size={14} />}{' '}
|
||||
{copied === 'link' ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
<a
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
href={session.link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icons.IconEye size={14} /> Open
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <Icons.IconChevronUp size={14} /> : <Icons.IconChevronDown size={14} />}{' '}
|
||||
Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
|
||||
{expanded && (
|
||||
<div className="proofing-card-body">
|
||||
<div className="proofing-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
onClick={copyLightroom}
|
||||
disabled={session.selected === 0}
|
||||
title="File names without extension, comma-separated — paste into Lightroom's or Capture One's filename filter to find the RAW files too"
|
||||
>
|
||||
{copied === 'lightroom' ? (
|
||||
<Icons.IconCheck size={14} />
|
||||
) : (
|
||||
<Icons.IconCopy size={14} />
|
||||
)}{' '}
|
||||
{copied === 'lightroom' ? 'Copied' : 'Copy file names (Lightroom)'}
|
||||
</button>
|
||||
<a
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
href={`/api/admin/proofing/${session.id}/export?format=csv`}
|
||||
>
|
||||
<Icons.IconFileText size={14} /> CSV
|
||||
</a>
|
||||
<a
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
href={`/api/admin/proofing/${session.id}/export?format=txt`}
|
||||
>
|
||||
<Icons.IconFileText size={14} /> TXT
|
||||
</a>
|
||||
{session.submittedAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
onClick={() => patch({ reopen: true })}
|
||||
>
|
||||
<Icons.IconRefresh size={14} /> Reopen for changes
|
||||
</button>
|
||||
)}
|
||||
{session.download !== 'none' && session.downloadsUsed > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
onClick={() => patch({ resetDownloads: true })}
|
||||
>
|
||||
<Icons.IconRefresh size={14} /> Reset downloads
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm admin-btn-icon-danger"
|
||||
onClick={remove}
|
||||
>
|
||||
<Icons.IconTrash size={14} /> Delete link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{picks === null ? (
|
||||
<div className="admin-spinner" />
|
||||
) : picks.length === 0 ? (
|
||||
<p className="analytics-empty">No photos selected yet.</p>
|
||||
) : (
|
||||
<ul className="proofing-picks">
|
||||
{picks.map((pick) => (
|
||||
<li key={pick.assetId} className="proofing-pick">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnails, served by our own proxy */}
|
||||
<img src={pick.thumbUrl} alt="" loading="lazy" />
|
||||
<span className="proofing-pick-name" title={pick.fileName}>
|
||||
#{pick.position} · {pick.fileName}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProofingView() {
|
||||
const [sessions, setSessions] = useState<AdminProofSession[] | null>(null);
|
||||
const [webhookConfigured, setWebhookConfigured] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api<{ sessions: AdminProofSession[]; webhookConfigured: boolean }>(
|
||||
'/api/admin/proofing',
|
||||
);
|
||||
setSessions(data.sessions);
|
||||
setWebhookConfigured(data.webhookConfigured);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// The webhook's admin link lands here with ?session=<id>: open that card.
|
||||
const wanted = new URLSearchParams(window.location.search).get('session');
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time read of the URL on mount
|
||||
if (wanted) setExpanded(wanted);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="analytics-view proofing-view">
|
||||
<PageHeader
|
||||
kicker="Visitors"
|
||||
title="Client proofing"
|
||||
description="A private link per client. Picks are saved as they go; you see them here once they submit."
|
||||
actions={
|
||||
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={() => void load()}>
|
||||
<Icons.IconRefresh size={14} /> Refresh
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!webhookConfigured && (
|
||||
<p className="analytics-subtitle">
|
||||
Tip: set <code>PROOFING_WEBHOOK_URL</code> to be notified (Discord, Slack, Gotify, n8n, …)
|
||||
when a client submits.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<CreateForm onCreated={() => void load()} />
|
||||
|
||||
{error && <p className="admin-error">{error}</p>}
|
||||
|
||||
{sessions === null && !error ? (
|
||||
<div className="admin-loading-container">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : sessions && sessions.length === 0 ? (
|
||||
<p className="analytics-empty">No client links yet.</p>
|
||||
) : (
|
||||
sessions?.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
expanded={expanded === session.id}
|
||||
onToggle={() => setExpanded((cur) => (cur === session.id ? null : session.id))}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,20 @@
|
||||
* path through useAboutEditor().
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import SaveBar, { type SaveStatus } from './SaveBar';
|
||||
import { useUnsavedGuard } from './useUnsavedGuard';
|
||||
import { useDraft } from './useDraft';
|
||||
import { useLatest } from './useLatest';
|
||||
import DraftNotice from './DraftNotice';
|
||||
import { useContentRestored } from './contentRestored';
|
||||
import { reportIfSessionExpired } from './sessionExpiry';
|
||||
import { useVersionedSave } from './useVersionedSave';
|
||||
import type { Settings, SectionProps } from './settings/types';
|
||||
import type { EnvLocks } from '@/lib/admin/envLocks';
|
||||
import { validateSettingValues } from '@/lib/config/settingValues';
|
||||
import GeneralSection from './settings/GeneralSection';
|
||||
import ThemeSection from './settings/ThemeSection';
|
||||
import GridSection from './settings/GridSection';
|
||||
@@ -65,6 +69,8 @@ export default function SettingsEditor() {
|
||||
effective: string | null;
|
||||
source: 'env' | 'settings' | 'none';
|
||||
} | null>(null);
|
||||
/** Fields an environment variable overrides, path to variable name (#605). */
|
||||
const [envLocks, setEnvLocks] = useState<EnvLocks>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
/**
|
||||
* Set when the settings could not be fetched. It blocks saving, because an
|
||||
@@ -87,6 +93,11 @@ export default function SettingsEditor() {
|
||||
// holds the password as typed. A restored draft takes the password from the
|
||||
// server instead; a new one is typed again after a reload.
|
||||
const settingsDraft = useDraft<Settings>('settings', withoutSitePassword(settings), dirty);
|
||||
/** The form as last rendered: tells a finished save whether editing went on meanwhile. */
|
||||
const latestSettings = useLatest(settings);
|
||||
/** settings.yaml as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||
const versionRef = useRef<string | null>(null);
|
||||
const versionedSave = useVersionedSave();
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -149,14 +160,16 @@ export default function SettingsEditor() {
|
||||
: `The server answered ${res.status}.`,
|
||||
);
|
||||
}
|
||||
const { settings: data, siteUrl } = await res.json();
|
||||
const { settings: data, siteUrl, envLocks: locks, version } = await res.json();
|
||||
const loaded: Settings = data || {};
|
||||
versionRef.current = typeof version === 'string' ? version : null;
|
||||
setServerSettings(loaded);
|
||||
const restoredDraft = settingsDraft.load(JSON.stringify(withoutSitePassword(loaded)));
|
||||
const restored = restoredDraft && withSitePasswordOf(restoredDraft, loaded);
|
||||
setSettings(restored ?? loaded);
|
||||
setDirty(restored !== null);
|
||||
setSiteUrlInfo(siteUrl ?? null);
|
||||
setEnvLocks(locks ?? {});
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
setLoadError(err instanceof Error ? err.message : 'The settings could not be loaded.');
|
||||
@@ -218,8 +231,9 @@ export default function SettingsEditor() {
|
||||
setSaving(true);
|
||||
setSaveStatus(null);
|
||||
|
||||
const sent = settings;
|
||||
// Clean up empty objects
|
||||
const cleaned = JSON.parse(JSON.stringify(settings));
|
||||
const cleaned = JSON.parse(JSON.stringify(sent));
|
||||
for (const key of Object.keys(cleaned)) {
|
||||
if (typeof cleaned[key] === 'object' && Object.keys(cleaned[key]).length === 0) {
|
||||
delete cleaned[key];
|
||||
@@ -227,39 +241,56 @@ export default function SettingsEditor() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: cleaned }),
|
||||
});
|
||||
const result = await versionedSave(
|
||||
'/api/admin/settings',
|
||||
{ settings: cleaned },
|
||||
versionRef,
|
||||
'The settings file',
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (result.kind === 'reload') {
|
||||
settingsDraft.discard();
|
||||
await loadSettings();
|
||||
} else if (result.kind === 'keep') {
|
||||
setSaveStatus({ kind: 'error', message: 'Not saved — the settings changed elsewhere.' });
|
||||
} else if (result.kind === 'saved') {
|
||||
const data = result.data;
|
||||
// The site password is stored hashed (#690); show that, not the typed text.
|
||||
const stored: Settings =
|
||||
typeof data.sitePassword === 'string'
|
||||
? { ...cleaned, sitePassword: data.sitePassword }
|
||||
: cleaned;
|
||||
// Edited while the request was out: those edits are not saved yet, so
|
||||
// they stay dirty, and a password retyped meanwhile is not replaced.
|
||||
const editedMeanwhile = latestSettings.current !== sent;
|
||||
if (typeof data.sitePassword === 'string') {
|
||||
setSettings((s) => ({ ...s, sitePassword: data.sitePassword }));
|
||||
const hashed = data.sitePassword;
|
||||
setSettings((s) =>
|
||||
s.sitePassword === sent.sitePassword ? { ...s, sitePassword: hashed } : s,
|
||||
);
|
||||
}
|
||||
setServerSettings(stored);
|
||||
settingsDraft.saved(JSON.stringify(withoutSitePassword(stored)));
|
||||
setDirty(false);
|
||||
setSaveStatus({ kind: 'success', message: data.message || 'Saved!' });
|
||||
settingsDraft.saved(JSON.stringify(withoutSitePassword(stored)), editedMeanwhile);
|
||||
if (!editedMeanwhile) setDirty(false);
|
||||
setSaveStatus({
|
||||
kind: 'success',
|
||||
message: typeof data.message === 'string' ? data.message : 'Saved!',
|
||||
});
|
||||
router.refresh();
|
||||
setTimeout(() => setSaveStatus(null), 5000);
|
||||
} else if (!reportIfSessionExpired(res)) {
|
||||
const err = await res.json();
|
||||
} else if (!reportIfSessionExpired(result.res)) {
|
||||
const err = result.data ?? {};
|
||||
// A rejected save names the fields that caused it. Listing them beats
|
||||
// "could not be saved" over a form with forty inputs; putting the
|
||||
// message next to each input is the job of #600.
|
||||
// "could not be saved" over a form with forty inputs; the value
|
||||
// checks also stand next to their inputs (fieldErrors).
|
||||
const fields: string[] = Array.isArray(err.fields)
|
||||
? err.fields.map((f: { field?: string }) => f.field || 'settings')
|
||||
? err.fields.map((f: { field?: string; message?: string }) =>
|
||||
f.message ? `${f.field || 'settings'}: ${f.message}` : f.field || 'settings',
|
||||
)
|
||||
: [];
|
||||
setSaveStatus({
|
||||
kind: 'error',
|
||||
message: `Error: ${err.error}${fields.length ? ` — ${fields.join(', ')}` : ''}`,
|
||||
message: `Error: ${err.error ?? `HTTP ${result.res.status}`}${fields.length ? ` — ${fields.join(', ')}` : ''}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -311,7 +342,10 @@ export default function SettingsEditor() {
|
||||
const saveBarStatus: SaveStatus =
|
||||
[saveStatus, about.status].find((st) => st?.kind === 'error') ?? saveStatus ?? about.status;
|
||||
|
||||
const props: SectionProps = { settings, update, updateMany };
|
||||
const fieldErrors = Object.fromEntries(
|
||||
validateSettingValues(settings).map((e) => [e.field, e.message]),
|
||||
);
|
||||
const props: SectionProps = { settings, update, updateMany, envLocks, fieldErrors };
|
||||
|
||||
return (
|
||||
<div className="settings-editor">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { JournalEditor } from '../journal/JournalEditor';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
/**
|
||||
* The page editor's Publish button flips the Draft flag and saves at once
|
||||
* (#722). When that save fails, the flipped flag is an unsaved edit: the editor
|
||||
* has to say so and keep it saveable, not show "Saved" over a page that is
|
||||
* still a draft on disk.
|
||||
*/
|
||||
|
||||
const MARKDOWN = '---\ntitle: "FAQ"\ndraft: true\n---\n\nHello\n';
|
||||
|
||||
let putStatus = 500;
|
||||
|
||||
beforeEach(() => {
|
||||
putStatus = 500;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
if (putStatus !== 200) {
|
||||
return new Response(JSON.stringify({ error: 'Failed to save page' }), {
|
||||
status: putStatus,
|
||||
});
|
||||
}
|
||||
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
page: { slug: 'faq', rawMarkdown: sent.rawMarkdown },
|
||||
version: 'v2',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ page: { slug: 'faq', rawMarkdown: MARKDOWN }, version: 'v1' }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
// Node 26 defines its own global localStorage (undefined without
|
||||
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
// Drafts live in sessionStorage; each test starts without one.
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderEditor() {
|
||||
return render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<JournalEditor slug="faq" kind="page" onBack={() => {}} />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('JournalEditor page mode: Publish', () => {
|
||||
it('leaves a failed publish as an unsaved change', async () => {
|
||||
renderEditor();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('alert').textContent).toContain('Failed to save page'),
|
||||
);
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' });
|
||||
expect((save as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('reads Saved after a publish that went through', async () => {
|
||||
putStatus = 200;
|
||||
renderEditor();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Publish' }));
|
||||
|
||||
await screen.findByRole('button', { name: 'Unpublish' });
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Saved/ })).toBeTruthy());
|
||||
});
|
||||
});
|
||||
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal file
91
app/admin/components/__tests__/PrivacyEditor-draft.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import PrivacyEditor from '../PrivacyEditor';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
/**
|
||||
* The privacy editor sits in Settings → Legal and unmounts when another
|
||||
* section opens. An unsaved policy used to go with it (A-7); it now keeps a
|
||||
* draft like the About editor (#554, #592).
|
||||
*/
|
||||
|
||||
let serverBody = 'Saved policy';
|
||||
|
||||
beforeEach(() => {
|
||||
serverBody = 'Saved policy';
|
||||
window.sessionStorage.clear();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
body: serverBody,
|
||||
enabled: true,
|
||||
facts: [],
|
||||
starter: '## Controller',
|
||||
version: 'v1',
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<PrivacyEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const editor = async () =>
|
||||
(await screen.findByLabelText('Privacy policy (Markdown)')) as HTMLTextAreaElement;
|
||||
|
||||
describe('PrivacyEditor draft (A-7)', () => {
|
||||
it('brings back unsaved text after the editor was unmounted', async () => {
|
||||
const first = mount();
|
||||
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||
first.unmount();
|
||||
|
||||
mount();
|
||||
expect((await editor()).value).toBe('Unsaved policy');
|
||||
expect(screen.getByText(/unsaved changes to the privacy policy were restored/)).toBeTruthy();
|
||||
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('discarding the draft shows the saved policy again', async () => {
|
||||
const first = mount();
|
||||
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||
first.unmount();
|
||||
|
||||
mount();
|
||||
await editor();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard changes' }));
|
||||
expect((await editor()).value).toBe('Saved policy');
|
||||
await waitFor(() => expect(window.sessionStorage.getItem('folio_draft_privacy')).toBeNull());
|
||||
});
|
||||
|
||||
it('holds a draft back when the file changed since, and asks', async () => {
|
||||
const first = mount();
|
||||
fireEvent.change(await editor(), { target: { value: 'Unsaved policy' } });
|
||||
first.unmount();
|
||||
|
||||
serverBody = 'Changed in another tab';
|
||||
mount();
|
||||
expect((await editor()).value).toBe('Changed in another tab');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Restore my changes' }));
|
||||
expect((await editor()).value).toBe('Unsaved policy');
|
||||
});
|
||||
});
|
||||
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal file
176
app/admin/components/__tests__/admin-dialogs-a11y.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
import AlbumPicker from '../AlbumPicker';
|
||||
import AlbumDrawer from '../page-builder/AlbumDrawer';
|
||||
import { StorySettingsModal } from '../journal/StorySettingsModal';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function Dialog({
|
||||
name,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const ref = useModalDialog(onClose);
|
||||
return (
|
||||
<div ref={ref} role="dialog" aria-modal="true" aria-label={name}>
|
||||
<button type="button">{name} first</button>
|
||||
{children}
|
||||
<button type="button">{name} last</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const escape = () => fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
|
||||
|
||||
describe('useModalDialog with nested dialogs (A-9)', () => {
|
||||
it('Escape closes only the innermost dialog', () => {
|
||||
const closeOuter = vi.fn();
|
||||
const closeInner = vi.fn();
|
||||
const { rerender } = render(<Dialog name="outer" onClose={closeOuter} />);
|
||||
rerender(
|
||||
<Dialog name="outer" onClose={closeOuter}>
|
||||
<Dialog name="inner" onClose={closeInner} />
|
||||
</Dialog>,
|
||||
);
|
||||
escape();
|
||||
expect(closeInner).toHaveBeenCalledTimes(1);
|
||||
expect(closeOuter).not.toHaveBeenCalled();
|
||||
|
||||
// Once the inner one is gone, the outer one answers again.
|
||||
rerender(<Dialog name="outer" onClose={closeOuter} />);
|
||||
escape();
|
||||
expect(closeOuter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves Escape to an expanded combobox', () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<Dialog name="d" onClose={close}>
|
||||
<button type="button" role="combobox" aria-expanded="true" aria-controls="x">
|
||||
sort
|
||||
</button>
|
||||
</Dialog>,
|
||||
);
|
||||
screen.getByRole('combobox').focus();
|
||||
escape();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves Escape to an item picked up with the keyboard (dnd-kit)', () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<Dialog name="d" onClose={close}>
|
||||
<div role="button" tabIndex={0} aria-roledescription="sortable" aria-pressed="true">
|
||||
tile
|
||||
</div>
|
||||
</Dialog>,
|
||||
);
|
||||
screen.getByText('tile').focus();
|
||||
escape();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlbumPicker from the keyboard (A-10)', () => {
|
||||
const albums = [
|
||||
{
|
||||
id: 'a1',
|
||||
albumName: 'Alps',
|
||||
description: '',
|
||||
thumbnailAssetId: null,
|
||||
assetCount: 3,
|
||||
isConfigured: false,
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
albumName: 'Baltic',
|
||||
description: '',
|
||||
thumbnailAssetId: null,
|
||||
assetCount: 5,
|
||||
isConfigured: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('offers each free album as a button and disables the ones in use', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<AlbumPicker
|
||||
albums={albums}
|
||||
onSelect={onSelect}
|
||||
onClose={() => {}}
|
||||
usedAlbumIds={new Set(['a2'])}
|
||||
/>,
|
||||
);
|
||||
const alps = screen.getByRole('button', { name: /Alps/ });
|
||||
const baltic = screen.getByRole('button', { name: /Baltic/ }) as HTMLButtonElement;
|
||||
expect(baltic.disabled).toBe(true);
|
||||
fireEvent.click(alps);
|
||||
expect(onSelect).toHaveBeenCalledWith('a1');
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AlbumPicker
|
||||
albums={albums}
|
||||
onSelect={() => {}}
|
||||
onClose={onClose}
|
||||
usedAlbumIds={new Set()}
|
||||
/>,
|
||||
);
|
||||
escape();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlbumDrawer as a dialog (A-9)', () => {
|
||||
it('is a labelled modal dialog that closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<AlbumDrawer
|
||||
album={{ id: 'a1' }}
|
||||
name="Alps"
|
||||
count={3}
|
||||
thumbnailId={null}
|
||||
onUpdate={() => {}}
|
||||
onRemove={() => {}}
|
||||
onClose={onClose}
|
||||
onPickHero={() => {}}
|
||||
onEditOrder={() => {}}
|
||||
/>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: /Edit Album Details/ });
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
escape();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySettingsModal as a dialog (A-9)', () => {
|
||||
it('is a labelled modal dialog that closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<StorySettingsModal
|
||||
frontmatter={{ title: 'Trip' }}
|
||||
onChange={() => {}}
|
||||
onPickAsset={() => {}}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: /Story Settings/ });
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
||||
escape();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal file
266
app/admin/components/__tests__/inflight-save-edits.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { JournalEditor } from '../journal/JournalEditor';
|
||||
import PrivacyEditor from '../PrivacyEditor';
|
||||
import SettingsEditor from '../SettingsEditor';
|
||||
import PageBuilder from '../PageBuilder';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
const nav = vi.hoisted(() => ({ section: 'general' }));
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
|
||||
useParams: () => ({ section: nav.section }),
|
||||
usePathname: () => '/admin/settings/general',
|
||||
}));
|
||||
|
||||
/**
|
||||
* A save handler closes over the state it started with. Typing on while the
|
||||
* request is out used to be marked saved when the answer came back: the Save
|
||||
* button went to "Saved" and disabled, the unsaved-changes guard and the draft
|
||||
* let go, and leaving the editor threw those edits away. Where the server
|
||||
* rewrote the text (a hashed password line), the newer text was also replaced
|
||||
* by the file as written.
|
||||
*/
|
||||
|
||||
/** A PUT that answers only when the test says so. */
|
||||
let releasePut: ((written?: string) => void) | null = null;
|
||||
let putBodies: unknown[] = [];
|
||||
|
||||
function stubFetch(
|
||||
loadResponse: unknown,
|
||||
putResponse: (sent: Record<string, unknown>, written?: string) => unknown,
|
||||
) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const sent = JSON.parse(init.body as string) as Record<string, unknown>;
|
||||
putBodies.push(sent);
|
||||
const written = await new Promise<string | undefined>((resolve) => {
|
||||
releasePut = resolve;
|
||||
});
|
||||
return new Response(JSON.stringify(putResponse(sent, written)), { status: 200 });
|
||||
}
|
||||
const body = typeof loadResponse === 'function' ? loadResponse(_url) : loadResponse;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nav.section = 'general';
|
||||
releasePut = null;
|
||||
putBodies = [];
|
||||
// Node 26 defines its own global localStorage (undefined without
|
||||
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const ENTRY = '---\ntitle: "Trip"\n---\n\nHello\n';
|
||||
|
||||
async function renderJournal() {
|
||||
stubFetch({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }, (sent, written) => ({
|
||||
success: true,
|
||||
entry: { slug: 'trip', rawMarkdown: written ?? sent.rawMarkdown },
|
||||
version: 'v2',
|
||||
}));
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<JournalEditor slug="trip" onBack={() => {}} />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Raw Markdown' }));
|
||||
return screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
describe('JournalEditor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
const editor = await renderJournal();
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
|
||||
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
// Still kept as a draft, so leaving the editor does not lose it.
|
||||
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toContain('Second');
|
||||
});
|
||||
|
||||
it('are not replaced by the file as written', async () => {
|
||||
const editor = await renderJournal();
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\nSecond\n` } });
|
||||
// The server rewrote what it was sent, as it does when it hashes a password.
|
||||
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Saving...' })).toBeNull());
|
||||
expect(editor.value).toBe(`${ENTRY}First\nSecond\n`);
|
||||
});
|
||||
|
||||
it('a save with no edits meanwhile still ends clean, showing the file as written', async () => {
|
||||
const editor = await renderJournal();
|
||||
fireEvent.change(editor, { target: { value: `${ENTRY}First\n` } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
await act(async () => releasePut!(`${ENTRY}First (as written)\n`));
|
||||
|
||||
await screen.findByRole('button', { name: /Saved/ });
|
||||
expect(editor.value).toBe(`${ENTRY}First (as written)\n`);
|
||||
expect(window.sessionStorage.getItem('folio_draft_journal-trip')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PrivacyEditor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
stubFetch(
|
||||
{ body: 'Old', enabled: true, facts: [], starter: '## Controller', version: 'v1' },
|
||||
() => ({ success: true, version: 'v2' }),
|
||||
);
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<PrivacyEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
const editor = (await screen.findByLabelText(
|
||||
'Privacy policy (Markdown)',
|
||||
)) as HTMLTextAreaElement;
|
||||
fireEvent.change(editor, { target: { value: 'First' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save privacy policy' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(editor, { target: { value: 'First and second' } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Privacy policy saved.');
|
||||
const save = screen.getByRole('button', { name: 'Save privacy policy' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(putBodies).toEqual([{ body: 'First' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsEditor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
stubFetch({ settings: { title: 'Old' }, version: 'v1', envLocks: {} }, () => ({
|
||||
success: true,
|
||||
version: 'v2',
|
||||
}));
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<SettingsEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
const title = (await screen.findByLabelText('Site Title')) as HTMLInputElement;
|
||||
fireEvent.change(title, { target: { value: 'First' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(title, { target: { value: 'First and second' } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Saved!');
|
||||
expect(title.value).toBe('First and second');
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(window.sessionStorage.getItem('folio_draft_settings')).toContain('First and second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('About editor: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable', async () => {
|
||||
nav.section = 'about';
|
||||
stubFetch(
|
||||
(url: string) =>
|
||||
url === '/api/admin/about'
|
||||
? { meta: { name: 'Ada' }, body: 'Old', version: 'v1' }
|
||||
: { settings: {}, version: 'v1', envLocks: {} },
|
||||
() => ({ success: true, version: 'v2' }),
|
||||
);
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<SettingsEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
const bio = (await screen.findByLabelText('Biography (Markdown)')) as HTMLTextAreaElement;
|
||||
fireEvent.change(bio, { target: { value: 'First' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.change(bio, { target: { value: 'First and second' } });
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Saved!');
|
||||
expect(bio.value).toBe('First and second');
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
expect(window.sessionStorage.getItem('folio_draft_about')).toContain('First and second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageBuilder: edits made while a save is in flight', () => {
|
||||
it('stay unsaved and saveable, and are not replaced by the file as written', async () => {
|
||||
stubFetch(
|
||||
(url: string) =>
|
||||
url === '/api/admin/gallery'
|
||||
? { gallery: { hero: [], albums: [], subpages: [] }, version: 'v1' }
|
||||
: url === '/api/admin/albums'
|
||||
? { albums: [] }
|
||||
: { pages: [], taken: { subpages: [], albums: [], journal: [] } },
|
||||
// As written: hashed passwords make the server copy differ from the one sent.
|
||||
(sent) => ({
|
||||
success: true,
|
||||
version: 'v2',
|
||||
gallery: { ...(sent.gallery as object), hero: ['written-by-server'] },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<PageBuilder />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /New subpage/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() => expect(releasePut).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New subpage/ }));
|
||||
await act(async () => releasePut!());
|
||||
|
||||
await screen.findByText('Saved successfully!');
|
||||
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement;
|
||||
expect(save.disabled).toBe(false);
|
||||
const kept = JSON.parse(window.sessionStorage.getItem('folio_draft_page-builder') ?? 'null');
|
||||
expect(kept?.value?.subpages).toHaveLength(2);
|
||||
expect((putBodies[0] as { gallery: { subpages: unknown[] } }).gallery.subpages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
196
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal file
196
app/admin/components/__tests__/inline-markdown-fields.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { JournalEditor } from '../journal/JournalEditor';
|
||||
import { EssayBlockEditor } from '../EssayBlockEditor';
|
||||
import { InlineMarkdownField } from '../InlineMarkdownField';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
/**
|
||||
* Text blocks are edited as markdown, not as the HTML the parser stores (A-5).
|
||||
* A reloaded entry used to show `<strong>` and `<a href=…>` in the textarea,
|
||||
* text typed into a tag was lost on save, and typed `**bold**` stayed literal
|
||||
* in the live preview until the first save.
|
||||
*/
|
||||
|
||||
const ID = '3f1c9a52-8d4e-4b7a-9c1d-0e2f3a4b5c6d';
|
||||
const ENTRY = [
|
||||
'---',
|
||||
'title: "Trip"',
|
||||
'---',
|
||||
'',
|
||||
'Hello **bold** and [Immich](https://immich.app).',
|
||||
'',
|
||||
'> A *quoted* line -- Someone',
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
'::facts',
|
||||
'Distance: **21** km',
|
||||
].join('\n');
|
||||
|
||||
beforeEach(() => {
|
||||
// The live preview's FadeIn reveals at once under reduced motion instead of
|
||||
// needing IntersectionObserver.
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: query.includes('reduce'),
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const sent = JSON.parse(init.body as string) as { rawMarkdown: string };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
entry: { slug: 'trip', rawMarkdown: sent.rawMarkdown },
|
||||
version: 'v2',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ entry: { slug: 'trip', rawMarkdown: ENTRY }, version: 'v1' }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
// Node 26 defines its own global localStorage (undefined without
|
||||
// --localstorage-file), which shadows jsdom's; give the editor a working one.
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function renderJournal() {
|
||||
render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<JournalEditor slug="trip" onBack={() => {}} />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
return (await screen.findByLabelText('Text')) as HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
/** The entry as the editor would save it, read from the Raw Markdown view. */
|
||||
function rawMarkdown(): string {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Raw Markdown' }));
|
||||
const value = (screen.getByPlaceholderText('Write Markdown here...') as HTMLTextAreaElement)
|
||||
.value;
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Visual Blocks' }));
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('journal editor: text blocks as markdown (A-5)', () => {
|
||||
it('shows the markdown source, not the stored HTML, after a load', async () => {
|
||||
const text = await renderJournal();
|
||||
expect(text.value).toBe('Hello **bold** and [Immich](https://immich.app).');
|
||||
expect(screen.getByDisplayValue('A *quoted* line')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('A **nice** view')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('**21** km')).toBeTruthy();
|
||||
expect(document.body.querySelector('textarea, input')).toBeTruthy();
|
||||
for (const field of document.body.querySelectorAll('textarea, input')) {
|
||||
expect((field as HTMLInputElement).value).not.toMatch(/<(strong|em|a)\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it('renders typed markdown in the live preview before any save', async () => {
|
||||
const text = await renderJournal();
|
||||
fireEvent.change(text, { target: { value: 'Now **strong** text.' } });
|
||||
const preview = document.querySelector('.journal-preview-frame')!;
|
||||
expect(preview.innerHTML).toContain('<strong>strong</strong>');
|
||||
expect(preview.textContent).not.toContain('**strong**');
|
||||
// What was typed stays as typed.
|
||||
expect(text.value).toBe('Now **strong** text.');
|
||||
});
|
||||
|
||||
it('keeps text typed inside a link label or bold run', async () => {
|
||||
const text = await renderJournal();
|
||||
fireEvent.change(text, {
|
||||
target: { value: 'Hello **bold and more** and [Immich Photos](https://immich.app).' },
|
||||
});
|
||||
expect(rawMarkdown()).toContain(
|
||||
'Hello **bold and more** and [Immich Photos](https://immich.app).',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the same file when the content is unchanged', async () => {
|
||||
const text = await renderJournal();
|
||||
const original = text.value;
|
||||
fireEvent.change(text, { target: { value: `${original} extra` } });
|
||||
fireEvent.change(screen.getByLabelText('Text'), { target: { value: original } });
|
||||
expect(rawMarkdown()).toBe(ENTRY);
|
||||
});
|
||||
|
||||
it('keeps `__` as typed while the file gets the canonical form', async () => {
|
||||
const text = await renderJournal();
|
||||
fireEvent.change(text, { target: { value: 'An __underscored__ word' } });
|
||||
expect(text.value).toBe('An __underscored__ word');
|
||||
expect(rawMarkdown()).toContain('An **underscored** word');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InlineMarkdownField', () => {
|
||||
function Harness({ initial }: { initial: string }) {
|
||||
const [html, setHtml] = useState(initial);
|
||||
return (
|
||||
<>
|
||||
<InlineMarkdownField aria-label="Field" html={html} onChange={setHtml} />
|
||||
<button type="button" onClick={() => setHtml('<em>replaced</em>')}>
|
||||
Replace
|
||||
</button>
|
||||
<output data-testid="html">{html}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it('hands back HTML and re-reads it only on an outside change', () => {
|
||||
render(<Harness initial="<strong>x</strong>" />);
|
||||
const field = screen.getByLabelText('Field') as HTMLInputElement;
|
||||
expect(field.value).toBe('**x**');
|
||||
fireEvent.change(field, { target: { value: 'a [half](http' } });
|
||||
expect(field.value).toBe('a [half](http');
|
||||
fireEvent.change(field, { target: { value: 'a & b <c>' } });
|
||||
expect(screen.getByTestId('html').textContent).toBe('a & b <c>');
|
||||
expect(field.value).toBe('a & b <c>');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
|
||||
expect(field.value).toBe('*replaced*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('essay block editor: text blocks as markdown (A-5)', () => {
|
||||
it('shows markdown and serializes an edit through the renderer', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<EssayBlockEditor
|
||||
markdown={'Some *italic* and [a link](https://immich.app).'}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const text = screen.getByDisplayValue(
|
||||
'Some *italic* and [a link](https://immich.app).',
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(text, {
|
||||
target: { value: 'Some *italic* and [a longer link](https://immich.app).' },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
'Some *italic* and [a longer link](https://immich.app).',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import SettingsEditor from '../SettingsEditor';
|
||||
import { NotificationProvider } from '../Notifications';
|
||||
import { ConfirmProvider } from '../ConfirmDialog';
|
||||
|
||||
const nav = vi.hoisted(() => ({ section: 'grid' }));
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ refresh: () => {}, push: () => {}, replace: () => {} }),
|
||||
useParams: () => ({ section: nav.section }),
|
||||
usePathname: () => `/admin/settings/${nav.section}`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* A value the site would ignore is named next to its input as it is typed,
|
||||
* not only after a refused save (QA A-14).
|
||||
*/
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ settings: { title: 'Folio' }, version: 'v1', envLocks: {} }),
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mount() {
|
||||
return render(
|
||||
<NotificationProvider>
|
||||
<ConfirmProvider>
|
||||
<SettingsEditor />
|
||||
</ConfirmProvider>
|
||||
</NotificationProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Settings form: values the site would ignore', () => {
|
||||
it('marks grid columns outside 1–6 at the input', async () => {
|
||||
nav.section = 'grid';
|
||||
mount();
|
||||
const columns = (await screen.findByLabelText(/Columns/)) as HTMLInputElement;
|
||||
expect(columns.getAttribute('aria-invalid')).toBeNull();
|
||||
|
||||
fireEvent.change(columns, { target: { value: '9' } });
|
||||
expect(columns.getAttribute('aria-invalid')).toBe('true');
|
||||
const error = document.getElementById(columns.getAttribute('aria-describedby')!);
|
||||
expect(error?.textContent).toBe('Must be a whole number from 1 to 6.');
|
||||
|
||||
fireEvent.change(columns, { target: { value: '4' } });
|
||||
expect(columns.getAttribute('aria-invalid')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks an accent that is not hex', async () => {
|
||||
nav.section = 'theme';
|
||||
mount();
|
||||
const accent = (await screen.findByLabelText('Accent colour (hex)')) as HTMLInputElement;
|
||||
fireEvent.change(accent, { target: { value: 'rot' } });
|
||||
expect(accent.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(screen.getByText('Must be a hex colour such as #e60012.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
24
app/admin/components/analyticsDays.ts
Normal file
24
app/admin/components/analyticsDays.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The days the analytics chart shows: the last `count` calendar days up to
|
||||
* `today`, each one present, with 0 for a day nobody visited.
|
||||
*
|
||||
* The chart used to take the last 14 *recorded* days, so a quiet week simply
|
||||
* vanished and "Last 14 Days" could span a month (QA A-16).
|
||||
*
|
||||
* Keys are UTC dates (`YYYY-MM-DD`), the way /api/analytics/track writes them,
|
||||
* so `today` is read in UTC too — a local date would be a day off either side
|
||||
* of midnight.
|
||||
*/
|
||||
export function lastDays(
|
||||
days: Record<string, { pageviews?: number } | undefined>,
|
||||
count: number,
|
||||
today: Date,
|
||||
): Array<{ date: string; pageviews: number }> {
|
||||
const end = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
||||
const out: Array<{ date: string; pageviews: number }> = [];
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const date = new Date(end - i * 86_400_000).toISOString().slice(0, 10);
|
||||
out.push({ date, pageviews: days[date]?.pageviews ?? 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
20
app/admin/components/fields/EnvLockNote.tsx
Normal file
20
app/admin/components/fields/EnvLockNote.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IconLock } from '../Icons';
|
||||
|
||||
/**
|
||||
* The note under a field an environment variable overrides (#605). The field
|
||||
* itself is rendered disabled by its section; this names the variable, so the
|
||||
* operator knows where the value actually lives. Never shows the value.
|
||||
*/
|
||||
export default function EnvLockNote({ id, variable }: { id?: string; variable: string }) {
|
||||
return (
|
||||
<p id={id} className="admin-env-lock">
|
||||
<span className="admin-env-lock-pill">
|
||||
<IconLock size={11} /> Locked
|
||||
</span>
|
||||
<span>
|
||||
Set by <code>{variable}</code> in the environment. Change or unset it there; edits here
|
||||
would have no effect.
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,17 @@ import {
|
||||
IconFolder,
|
||||
IconX,
|
||||
} from '../Icons';
|
||||
import { isLegacyAssetRef } from './blockOps';
|
||||
import { InlineMarkdownField } from '../InlineMarkdownField';
|
||||
import { fillAssetSlots, isLegacyAssetRef } from './blockOps';
|
||||
|
||||
/** What the asset picker is opened for: its title, and where the pick goes. */
|
||||
export interface AssetPickTarget {
|
||||
title: string;
|
||||
onSelect: (assetId: string) => void;
|
||||
onSelect?: (assetId: string) => void;
|
||||
/** Multi-select: several picks confirmed at once (#602). */
|
||||
onSelectMany?: (assetIds: string[]) => void;
|
||||
/** Most assets `onSelectMany` takes. */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface BlockFieldsProps {
|
||||
@@ -71,22 +76,26 @@ export function BlockFields({
|
||||
)}
|
||||
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
<InlineMarkdownField
|
||||
multiline
|
||||
aria-label="Text"
|
||||
className="admin-input"
|
||||
rows={3}
|
||||
value={block.html}
|
||||
onChange={(e) => onChange({ ...block, html: e.target.value })}
|
||||
placeholder="Text (**bold**, *italic*, [link](https://…))"
|
||||
html={block.html}
|
||||
onChange={(html) => onChange({ ...block, html })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<textarea
|
||||
<InlineMarkdownField
|
||||
multiline
|
||||
className="admin-input"
|
||||
rows={2}
|
||||
value={block.text}
|
||||
html={block.text}
|
||||
placeholder="Quote text..."
|
||||
onChange={(e) => onChange({ ...block, text: e.target.value })}
|
||||
onChange={(text) => onChange({ ...block, text })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -176,12 +185,11 @@ export function BlockFields({
|
||||
Change Photo
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Caption (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -242,12 +250,31 @@ export function BlockFields({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onPickAsset({
|
||||
title: 'Select Photos for Pair',
|
||||
max: 2,
|
||||
onSelectMany: (ids) => {
|
||||
// Two picks replace the pair; a single pick fills a gap.
|
||||
const base = ids.length >= 2 ? ['', ''] : [...block.assetIds];
|
||||
const [a, b] = fillAssetSlots(base, ids, 2);
|
||||
onChange({ ...block, assetIds: [a ?? '', b ?? ''] });
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Pick both photos
|
||||
</button>
|
||||
</div>
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Shared caption for pair (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -316,16 +343,28 @@ export function BlockFields({
|
||||
>
|
||||
<IconPlus size={12} /> Add photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onPickAsset({
|
||||
title: 'Select Photos for Grid',
|
||||
onSelectMany: (ids) =>
|
||||
onChange({ ...block, assetIds: fillAssetSlots(block.assetIds, ids) }),
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Pick several
|
||||
</button>
|
||||
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Three or more photos, laid out in rows of three.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Shared caption for grid (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -348,17 +387,14 @@ export function BlockFields({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Value, e.g. 21 km"
|
||||
value={item.value}
|
||||
onChange={(e) =>
|
||||
html={item.value}
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
...block,
|
||||
items: block.items.map((it, i) =>
|
||||
i === fIdx ? { ...it, value: e.target.value } : it,
|
||||
),
|
||||
items: block.items.map((it, i) => (i === fIdx ? { ...it, value } : it)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -467,17 +503,11 @@ export function BlockFields({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Caption for the set (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...block,
|
||||
caption: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption: caption || undefined })}
|
||||
/>
|
||||
<span style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Photos follow the album's order (a manual order in the gallery comes first). Count
|
||||
@@ -494,12 +524,11 @@ export function BlockFields({
|
||||
enabled.
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
<InlineMarkdownField
|
||||
className="admin-input"
|
||||
placeholder="Caption, e.g. Busan → Seoul (optional)"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) => onChange({ ...block, caption: e.target.value })}
|
||||
html={block.caption}
|
||||
onChange={(caption) => onChange({ ...block, caption })}
|
||||
/>
|
||||
|
||||
{block.items.map((item, mIdx) => {
|
||||
|
||||
@@ -28,14 +28,17 @@ import type { AlbumAssetRef } from '@/lib/journalAlbum';
|
||||
import { BlockBadge } from '../BlockBadge';
|
||||
import { useUnsavedGuard } from '../useUnsavedGuard';
|
||||
import { useDraft } from '../useDraft';
|
||||
import { useLatest } from '../useLatest';
|
||||
import DraftNotice from '../DraftNotice';
|
||||
import { reportIfSessionExpired } from '../sessionExpiry';
|
||||
import { useContentRestored } from '../contentRestored';
|
||||
import { useVersionedSave } from '../useVersionedSave';
|
||||
import './journal-studio.css';
|
||||
import { BlockFields, type AssetPickTarget } from './BlockFields';
|
||||
import { StorySettingsModal } from './StorySettingsModal';
|
||||
import { PageSettingsPanel } from './PageSettingsPanel';
|
||||
import { JournalPreview } from './JournalPreview';
|
||||
import { createBlock, moveBlock } from './blockOps';
|
||||
import { createBlock, createPhotoBlocks, moveBlock } from './blockOps';
|
||||
import { useSplitPane, SPLIT_MIN, SPLIT_MAX } from './splitPane';
|
||||
import { useNotify } from '../Notifications';
|
||||
|
||||
@@ -43,9 +46,18 @@ interface JournalEditorProps {
|
||||
slug: string;
|
||||
mapEnabled?: boolean;
|
||||
onBack: () => void;
|
||||
/**
|
||||
* `page` edits a content page (#722) instead of a journal entry: its own
|
||||
* API and live URL, no map block, a settings side panel instead of the
|
||||
* story settings, and a Publish button that saves the Draft flag at once.
|
||||
*/
|
||||
kind?: 'journal' | 'page';
|
||||
}
|
||||
|
||||
export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps) {
|
||||
export function JournalEditor({ slug, mapEnabled, onBack, kind = 'journal' }: JournalEditorProps) {
|
||||
const isPage = kind === 'page';
|
||||
const apiUrl = isPage ? `/api/admin/pages/${slug}` : `/api/admin/journal/${slug}`;
|
||||
const liveUrl = isPage ? `/${slug}` : `/journal/${slug}`;
|
||||
const notify = useNotify();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -63,6 +75,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
} = useSplitPane();
|
||||
|
||||
const [rawMarkdown, setRawMarkdown] = useState('');
|
||||
/** The markdown as last rendered: tells a finished save whether typing went on meanwhile. */
|
||||
const latestMarkdown = useLatest(rawMarkdown);
|
||||
const [parsed, setParsed] = useState<ParsedJournal>(() => ({
|
||||
frontmatter: {},
|
||||
blocks: [],
|
||||
@@ -81,14 +95,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
// Unsaved edits survive leaving the editor — the entry list, a tab link,
|
||||
// back, Reload (#592). The markdown is the whole entry; blocks and
|
||||
// frontmatter are parsed from it. `serverMarkdown` is what a discard restores.
|
||||
const draft = useDraft<string>(`journal-${slug}`, rawMarkdown, dirty);
|
||||
const draft = useDraft<string>(`${isPage ? 'page' : 'journal'}-${slug}`, rawMarkdown, dirty);
|
||||
const loadDraft = draft.load;
|
||||
const serverMarkdown = useRef('');
|
||||
/** The file as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||
const versionRef = useRef<string | null>(null);
|
||||
const versionedSave = useVersionedSave();
|
||||
|
||||
// Bumped when this entry is restored from a backup, to load it again.
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
useContentRestored(({ target, slug: restored }) => {
|
||||
if (target === 'journal' && restored === slug) setReloadKey((k) => k + 1);
|
||||
if (target === (isPage ? 'pages' : 'journal') && restored === slug) setReloadKey((k) => k + 1);
|
||||
});
|
||||
|
||||
// Load entry
|
||||
@@ -97,7 +114,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`);
|
||||
const res = await fetch(apiUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
@@ -106,7 +123,8 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
const md: string = data.entry.rawMarkdown;
|
||||
versionRef.current = typeof data.version === 'string' ? data.version : null;
|
||||
const md: string = (isPage ? data.page : data.entry).rawMarkdown;
|
||||
serverMarkdown.current = md;
|
||||
const restored = loadDraft(md);
|
||||
setRawMarkdown(restored ?? md);
|
||||
@@ -122,7 +140,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [slug, loadDraft, reloadKey]);
|
||||
}, [slug, apiUrl, isPage, loadDraft, reloadKey]);
|
||||
|
||||
// Update markdown and sync blocks
|
||||
const handleMarkdownChange = (newMd: string) => {
|
||||
@@ -159,44 +177,61 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
// Save
|
||||
const handleSave = async () => {
|
||||
// Save. `override` is markdown to save instead of the editor's — the page
|
||||
// editor's Publish button changes the Draft flag and saves in one go.
|
||||
const handleSave = async (override?: string) => {
|
||||
// The editor is not rendered in this state, but Cmd+S still reaches here.
|
||||
if (loadError) return;
|
||||
// Nothing to save: every save rotates a backup, so repeated Cmd+S on an
|
||||
// unchanged entry pushed real history out of the ten kept per file.
|
||||
if (!dirty || saving) return;
|
||||
if ((!dirty && override === undefined) || saving) return;
|
||||
const toSave = override ?? rawMarkdown;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rawMarkdown,
|
||||
}),
|
||||
});
|
||||
const result = await versionedSave(
|
||||
apiUrl,
|
||||
{ rawMarkdown: toSave },
|
||||
versionRef,
|
||||
isPage ? 'This page' : 'This entry',
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
if (result.kind === 'reload') {
|
||||
draft.discard();
|
||||
setReloadKey((k) => k + 1);
|
||||
} else if (result.kind === 'keep') {
|
||||
notify('error', 'Not saved — it changed elsewhere. Your edits are still here.');
|
||||
} else if (result.kind === 'saved') {
|
||||
// The file as written, not as sent: a password line is hashed on the
|
||||
// way to disk (#690). Taking the sent text as the base would make the
|
||||
// next draft look like a conflicting edit from elsewhere.
|
||||
const data = await res.json().catch(() => null);
|
||||
const data = result.data as {
|
||||
page?: { rawMarkdown?: unknown };
|
||||
entry?: { rawMarkdown?: unknown };
|
||||
};
|
||||
const record = isPage ? data.page : data.entry;
|
||||
const written: string =
|
||||
typeof data?.entry?.rawMarkdown === 'string' ? data.entry.rawMarkdown : rawMarkdown;
|
||||
typeof record?.rawMarkdown === 'string' ? record.rawMarkdown : toSave;
|
||||
serverMarkdown.current = written;
|
||||
draft.saved(written);
|
||||
if (written !== rawMarkdown) {
|
||||
setRawMarkdown(written);
|
||||
setParsed(parseJournalMarkdown(written));
|
||||
// Edited while the request was out: those edits are not saved yet, so
|
||||
// they stay dirty and are not replaced by the file as written.
|
||||
const editedMeanwhile = latestMarkdown.current !== toSave;
|
||||
draft.saved(written, editedMeanwhile);
|
||||
if (!editedMeanwhile) {
|
||||
if (written !== toSave) {
|
||||
setRawMarkdown(written);
|
||||
setParsed(parseJournalMarkdown(written));
|
||||
}
|
||||
setDirty(false);
|
||||
}
|
||||
setDirty(false);
|
||||
} else if (!reportIfSessionExpired(res)) {
|
||||
const data = await res.json();
|
||||
notify('error', data.error || 'Failed to save');
|
||||
} else if (!reportIfSessionExpired(result.res)) {
|
||||
notify('error', result.data?.error || 'Failed to save');
|
||||
}
|
||||
} catch {
|
||||
notify('error', 'Could not save the entry. Check the connection and try again.');
|
||||
notify(
|
||||
'error',
|
||||
`Could not save the ${isPage ? 'page' : 'entry'}. Check the connection and try again.`,
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -278,6 +313,22 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
};
|
||||
|
||||
// Block manipulation
|
||||
/** Page mode: publish or unpublish, saved at once. Same flag as the Draft pill. */
|
||||
const handleTogglePublish = () => {
|
||||
const updated: ParsedJournal = {
|
||||
...parsed,
|
||||
frontmatter: { ...parsed.frontmatter, draft: !parsed.frontmatter.draft },
|
||||
};
|
||||
const serialized = serializeJournalMarkdown(updated);
|
||||
setParsed(updated);
|
||||
setRawMarkdown(serialized);
|
||||
// An edit like any other until the save lands: a failed or refused save
|
||||
// must leave the flipped flag unsaved, not shown as "Saved" (a success
|
||||
// clears it again).
|
||||
setDirty(true);
|
||||
handleSave(serialized);
|
||||
};
|
||||
|
||||
const handleAddBlock = (type: JournalBlock['type']) => {
|
||||
handleBlocksChange([...parsed.blocks, createBlock(type)]);
|
||||
};
|
||||
@@ -304,7 +355,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '4rem', textAlign: 'center', opacity: 0.6 }}>
|
||||
Opening Journal Studio...
|
||||
{isPage ? 'Opening page editor...' : 'Opening Journal Studio...'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -316,13 +367,13 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="admin-error" role="alert">
|
||||
<strong>This entry could not be loaded.</strong> {loadError}
|
||||
<strong>This {isPage ? 'page' : 'entry'} could not be loaded.</strong> {loadError}
|
||||
<p>
|
||||
Nothing has been changed. Saving stays disabled until it loads, so an empty editor cannot
|
||||
overwrite the entry.
|
||||
overwrite it.
|
||||
</p>
|
||||
<button className="admin-btn admin-btn-secondary" onClick={onBack}>
|
||||
Back to entries
|
||||
{isPage ? 'Back to pages' : 'Back to entries'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -338,15 +389,15 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
onClick={onBack}
|
||||
>
|
||||
← All Entries
|
||||
{isPage ? '← Pages' : '← All Entries'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="journal-editor-title-input"
|
||||
value={parsed.frontmatter.title || ''}
|
||||
placeholder="Story Title..."
|
||||
aria-label="Story title"
|
||||
placeholder={isPage ? 'Page title...' : 'Story Title...'}
|
||||
aria-label={isPage ? 'Page title' : 'Story title'}
|
||||
onChange={(e) => handleFrontmatterChange({ title: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -382,11 +433,27 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
onClick={() => setShowMetaModal(true)}
|
||||
>
|
||||
<IconGear size={14} /> Story Settings
|
||||
<IconGear size={14} /> {isPage ? 'Page Settings' : 'Story Settings'}
|
||||
</button>
|
||||
|
||||
{isPage && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
onClick={handleTogglePublish}
|
||||
disabled={saving}
|
||||
title={
|
||||
parsed.frontmatter.draft
|
||||
? 'Publish the page and save it now'
|
||||
: 'Turn the page back into a draft and save it now'
|
||||
}
|
||||
>
|
||||
{parsed.frontmatter.draft ? 'Publish' : 'Unpublish'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/journal/${slug}`}
|
||||
href={liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-sm admin-btn-secondary"
|
||||
@@ -397,7 +464,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={handleSave}
|
||||
onClick={() => handleSave()}
|
||||
disabled={saving || !dirty}
|
||||
>
|
||||
{saving ? (
|
||||
@@ -415,7 +482,7 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
|
||||
<DraftNotice
|
||||
status={draft.status}
|
||||
subject="entry"
|
||||
subject={isPage ? 'page' : 'entry'}
|
||||
onDiscard={discardDraft}
|
||||
onRestore={restoreConflictingDraft}
|
||||
onDismiss={draft.dismiss}
|
||||
@@ -477,6 +544,19 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
>
|
||||
<IconCamera size={13} /> + Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() =>
|
||||
setAssetPickerTarget({
|
||||
title: 'Add Photos (one block each)',
|
||||
onSelectMany: (ids) =>
|
||||
handleBlocksChange([...parsed.blocks, ...createPhotoBlocks(ids)]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconCamera size={13} /> + Photos
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
@@ -498,13 +578,16 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
>
|
||||
<IconFolder size={13} /> + Album
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('map')}
|
||||
>
|
||||
<IconMap size={13} /> + Map
|
||||
</button>
|
||||
{/* No map block on content pages in v1 (#722). */}
|
||||
{!isPage && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('map')}
|
||||
>
|
||||
<IconMap size={13} /> + Map
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocks List */}
|
||||
@@ -591,8 +674,17 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
<JournalPreview parsed={parsed} albumAssets={albumAssets} />
|
||||
</div>
|
||||
|
||||
{/* Page settings: a side panel, so editing never means going back (#722). */}
|
||||
{showMetaModal && isPage && (
|
||||
<PageSettingsPanel
|
||||
frontmatter={parsed.frontmatter}
|
||||
onChange={handleFrontmatterChange}
|
||||
onClose={() => setShowMetaModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Metadata Modal */}
|
||||
{showMetaModal && (
|
||||
{showMetaModal && !isPage && (
|
||||
<StorySettingsModal
|
||||
frontmatter={parsed.frontmatter}
|
||||
onChange={handleFrontmatterChange}
|
||||
@@ -618,10 +710,18 @@ export function JournalEditor({ slug, mapEnabled, onBack }: JournalEditorProps)
|
||||
{assetPickerTarget && (
|
||||
<AssetPicker
|
||||
title={assetPickerTarget.title}
|
||||
max={assetPickerTarget.max}
|
||||
onSelect={(id) => {
|
||||
assetPickerTarget.onSelect(id);
|
||||
assetPickerTarget.onSelect?.(id);
|
||||
setAssetPickerTarget(null);
|
||||
}}
|
||||
onSelectMany={
|
||||
assetPickerTarget.onSelectMany &&
|
||||
((ids) => {
|
||||
assetPickerTarget.onSelectMany?.(ids);
|
||||
setAssetPickerTarget(null);
|
||||
})
|
||||
}
|
||||
onClose={() => setAssetPickerTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { JournalEntrySummary } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, sanitizeSlug } from '@/lib/journal';
|
||||
import { serializeJournalMarkdown, sanitizeSlug, slugFieldValue } from '@/lib/journal';
|
||||
import { JOURNAL_TEMPLATES } from '@/lib/journalTemplates';
|
||||
import {
|
||||
IconTrash,
|
||||
@@ -22,6 +22,7 @@ import { useContentRestored } from '../contentRestored';
|
||||
import AdminLoadState from '../AdminLoadState';
|
||||
import PageHeader from '../PageHeader';
|
||||
import { useConfirm } from '../ConfirmDialog';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
interface JournalStudioProps {
|
||||
/** Entry to open, taken from the /admin/journal/[slug] route. */
|
||||
@@ -50,6 +51,11 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
const [creating, setCreating] = useState(false);
|
||||
/** null = start blank, matching the previous (only) behavior. */
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
const closeCreateModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateId(null);
|
||||
};
|
||||
const createCardRef = useModalDialog(closeCreateModal, showCreateModal);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -254,8 +260,16 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="journal-modal-overlay">
|
||||
<div className="journal-modal-card">
|
||||
<h3 style={{ margin: '0 0 1rem' }}>Create New Journal Entry</h3>
|
||||
<div
|
||||
className="journal-modal-card"
|
||||
ref={createCardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="journal-new-dialog-title"
|
||||
>
|
||||
<h3 id="journal-new-dialog-title" style={{ margin: '0 0 1rem' }}>
|
||||
Create New Journal Entry
|
||||
</h3>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
@@ -304,13 +318,13 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
className="admin-input"
|
||||
placeholder="e.g. expedition-nordkap"
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(sanitizeSlug(e.target.value))}
|
||||
onChange={(e) => setNewSlug(slugFieldValue(e.target.value))}
|
||||
required
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '2px', display: 'block' }}
|
||||
>
|
||||
Will be accessible at /journal/{newSlug || 'slug'}
|
||||
Will be accessible at /journal/{newSlug ? sanitizeSlug(newSlug) : 'slug'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -352,10 +366,7 @@ export function JournalStudio({ slug: activeSlug, mapEnabled }: JournalStudioPro
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
onClick={closeCreateModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal file
82
app/admin/components/journal/PageSettingsPanel.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import { IconX } from '../Icons';
|
||||
|
||||
type Frontmatter = ParsedJournal['frontmatter'];
|
||||
|
||||
interface PageSettingsPanelProps {
|
||||
frontmatter: Frontmatter;
|
||||
onChange: (updates: Partial<Frontmatter>) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content page's settings inside the editor (#722): a side panel rather than
|
||||
* the journal's modal, so the preview stays in view. The slug and the menu
|
||||
* place are edited in Pages, where the collision check and the menu live.
|
||||
*/
|
||||
export function PageSettingsPanel({ frontmatter, onChange, onClose }: PageSettingsPanelProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<aside className="page-settings-panel" aria-label="Page settings">
|
||||
<div className="page-settings-panel-head">
|
||||
<h3>Page settings</h3>
|
||||
<button type="button" className="admin-btn-icon" onClick={onClose} title="Close">
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="page-settings-description">SEO description</label>
|
||||
<textarea
|
||||
id="page-settings-description"
|
||||
rows={3}
|
||||
value={frontmatter.description || ''}
|
||||
placeholder="One or two sentences for search engines and link previews"
|
||||
// One line in the frontmatter: a line break would end the value.
|
||||
onChange={(e) => onChange({ description: e.target.value.replace(/\s*\n\s*/g, ' ') })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="setting-row"
|
||||
onClick={() => onChange({ draft: !frontmatter.draft })}
|
||||
aria-pressed={!!frontmatter.draft}
|
||||
>
|
||||
<span className="setting-row-info">
|
||||
<span className="setting-row-title">Draft</span>
|
||||
<span className="setting-row-desc">
|
||||
Only you can see a draft, and it stays out of the menu and the sitemap.
|
||||
</span>
|
||||
</span>
|
||||
<span className={`switch-toggle switch-toggle--sm ${frontmatter.draft ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="admin-field">
|
||||
<span className="admin-field-label">Password (optional)</span>
|
||||
<PasswordField
|
||||
value={frontmatter.password || undefined}
|
||||
onChange={(password) => onChange({ password: password ?? '' })}
|
||||
label="Page password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="page-settings-panel-note">
|
||||
Settings are saved with the page. The URL and the menu place are set in Pages.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ParsedJournal } from '@/lib/journal';
|
||||
import type { AssetPickTarget } from './BlockFields';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
type Frontmatter = ParsedJournal['frontmatter'];
|
||||
|
||||
@@ -20,10 +21,20 @@ export function StorySettingsModal({
|
||||
onPickAsset,
|
||||
onClose,
|
||||
}: StorySettingsModalProps) {
|
||||
const cardRef = useModalDialog(onClose);
|
||||
|
||||
return (
|
||||
<div className="journal-modal-overlay">
|
||||
<div className="journal-modal-card">
|
||||
<h3 style={{ margin: '0 0 1.25rem' }}>Story Settings & Metadata</h3>
|
||||
<div
|
||||
className="journal-modal-card"
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="story-settings-title"
|
||||
>
|
||||
<h3 id="story-settings-title" style={{ margin: '0 0 1.25rem' }}>
|
||||
Story Settings & Metadata
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
@@ -153,6 +164,7 @@ export function StorySettingsModal({
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="story-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
@@ -162,11 +174,15 @@ export function StorySettingsModal({
|
||||
>
|
||||
Password Protection (Optional)
|
||||
</label>
|
||||
<PasswordField
|
||||
value={frontmatter.password || undefined}
|
||||
onChange={(password) => onChange({ password: password ?? '' })}
|
||||
label="Entry password"
|
||||
/>
|
||||
{/* The input takes its styles from .admin-field, as everywhere else
|
||||
PasswordField is used; without it the lock icon sat on the text. */}
|
||||
<div className="admin-field" style={{ marginBottom: 0 }}>
|
||||
<PasswordField
|
||||
id="story-password"
|
||||
value={frontmatter.password || undefined}
|
||||
onChange={(password) => onChange({ password: password ?? '' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', paddingTop: '4px' }}>
|
||||
|
||||
@@ -17,6 +17,26 @@ export function isLegacyAssetRef(assetId: string): boolean {
|
||||
return assetId.length > 0 && !ASSET_UUID.test(assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slots after a multi-pick (#602): picked photos fill the empty slots first,
|
||||
* in pick order, and the rest are appended. With `capacity` (a pair holds two)
|
||||
* the list never grows past it; overflow picks are dropped.
|
||||
*/
|
||||
export function fillAssetSlots(slots: string[], picked: string[], capacity?: number): string[] {
|
||||
const next = [...slots];
|
||||
const queue = [...picked];
|
||||
for (let i = 0; i < next.length && queue.length > 0; i++) {
|
||||
if (!next[i]) next[i] = queue.shift()!;
|
||||
}
|
||||
next.push(...queue);
|
||||
return capacity === undefined ? next : next.slice(0, capacity);
|
||||
}
|
||||
|
||||
/** One photo block per picked asset, for "+ Photos" (#602). */
|
||||
export function createPhotoBlocks(assetIds: string[]): JournalBlock[] {
|
||||
return assetIds.map((assetId) => ({ type: 'photo', assetId, caption: '', layout: 'contained' }));
|
||||
}
|
||||
|
||||
/** The block the "Add Block" toolbar inserts for each type. */
|
||||
export function createBlock(type: JournalBlock['type']): JournalBlock {
|
||||
switch (type) {
|
||||
|
||||
@@ -104,7 +104,9 @@
|
||||
|
||||
.journal-list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* min(): on a phone the column is narrower than 320px, and a fixed minimum
|
||||
pushed every card past the right edge. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
||||
gap: 1.5rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
@@ -680,6 +682,12 @@
|
||||
Below 700px it wraps: back button and mode toggle, the title on a line of
|
||||
its own, then the actions with Save taking the remaining width. */
|
||||
@media (max-width: 700px) {
|
||||
/* The workspace already keeps a gutter; a second 2rem one left the cards
|
||||
under 300px wide. */
|
||||
.journal-list-grid {
|
||||
padding: 1.25rem 0;
|
||||
}
|
||||
|
||||
.journal-editor-topbar {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -710,3 +718,43 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Content page settings: a side panel, not a modal (#722) ── */
|
||||
.page-settings-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(360px, 100vw);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
background: var(--admin-bg-raised);
|
||||
border-left: 1px solid var(--admin-border);
|
||||
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.page-settings-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-settings-panel-head h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.page-settings-panel textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.page-settings-panel-note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Listbox } from '../Listbox';
|
||||
import { SORT_OPTIONS } from './sortOptions';
|
||||
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
interface AlbumDrawerProps {
|
||||
album: AlbumEntry;
|
||||
@@ -36,15 +37,29 @@ export default function AlbumDrawer({
|
||||
onEditOrder,
|
||||
}: AlbumDrawerProps) {
|
||||
const heroThumb = album.heroImage || thumbnailId;
|
||||
const cardRef = useModalDialog(onClose);
|
||||
|
||||
return (
|
||||
<div className="album-drawer-overlay open" onClick={() => onClose()}>
|
||||
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="album-drawer"
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="album-drawer-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="album-drawer-header">
|
||||
<h3>
|
||||
<h3 id="album-drawer-title">
|
||||
<IconCamera /> Edit Album Details
|
||||
</h3>
|
||||
<button className="admin-btn-icon" onClick={() => onClose()} title="Close details">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => onClose()}
|
||||
title="Close details"
|
||||
aria-label="Close details"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
134
app/admin/components/page-builder/NewPageDialog.tsx
Normal file
134
app/admin/components/page-builder/NewPageDialog.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { sanitizeSlug, slugFieldValue } from '@/lib/journal';
|
||||
import { describeCollision, pageSlugCollision, type SlugTakenBy } from '@/lib/pages';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
interface NewPageDialogProps {
|
||||
taken: SlugTakenBy;
|
||||
existingPageSlugs: string[];
|
||||
creating: boolean;
|
||||
onCreate: (input: { title: string; slug: string; showInMenu: boolean }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "+ New page" (#722): a title, the slug derived from it (editable), and
|
||||
* whether the page goes into the menu. v1 starts every page blank.
|
||||
*/
|
||||
export default function NewPageDialog({
|
||||
taken,
|
||||
existingPageSlugs,
|
||||
creating,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: NewPageDialogProps) {
|
||||
const cardRef = useModalDialog(onClose);
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugEdited, setSlugEdited] = useState(false);
|
||||
const [showInMenu, setShowInMenu] = useState(true);
|
||||
|
||||
/** What the field shows: the typed slug, or the one derived from the title. */
|
||||
const fieldSlug = slug || (title.trim() ? sanitizeSlug(title) : '');
|
||||
/** What gets created: the typed slug finished, e.g. without a trailing "-". */
|
||||
const effectiveSlug = slug ? sanitizeSlug(slug) : fieldSlug;
|
||||
const collision = !effectiveSlug
|
||||
? null
|
||||
: existingPageSlugs.includes(effectiveSlug)
|
||||
? `A page "${effectiveSlug}" already exists.`
|
||||
: (() => {
|
||||
const c = pageSlugCollision(effectiveSlug, taken);
|
||||
return c ? describeCollision(effectiveSlug, c) : null;
|
||||
})();
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !effectiveSlug || collision || creating) return;
|
||||
onCreate({ title: title.trim(), slug: effectiveSlug, showInMenu });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="confirm-backdrop" onClick={onClose}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="confirm-card new-page-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="new-page-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="new-page-title" className="confirm-title">
|
||||
New page
|
||||
</h2>
|
||||
<form onSubmit={submit} className="new-page-form">
|
||||
<div className="admin-field">
|
||||
<label htmlFor="new-page-name">Title</label>
|
||||
<input
|
||||
id="new-page-name"
|
||||
value={title}
|
||||
placeholder="e.g. Pricing"
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
if (!slugEdited) setSlug('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="new-page-slug">URL</label>
|
||||
<div className="input-slug-wrapper">
|
||||
<input
|
||||
id="new-page-slug"
|
||||
className="subpage-name-input"
|
||||
value={fieldSlug}
|
||||
placeholder="pricing"
|
||||
aria-describedby="new-page-slug-hint"
|
||||
onChange={(e) => {
|
||||
setSlugEdited(e.target.value !== '');
|
||||
setSlug(slugFieldValue(e.target.value));
|
||||
}}
|
||||
/>
|
||||
<span className="input-slug-preview">/{effectiveSlug || 'slug'}</span>
|
||||
</div>
|
||||
<span
|
||||
id="new-page-slug-hint"
|
||||
className={`admin-field-hint${collision ? ' admin-field-hint--error' : ''}`}
|
||||
>
|
||||
{collision ?? 'Starts blank and as a draft. You can change the URL later.'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="setting-row"
|
||||
onClick={() => setShowInMenu(!showInMenu)}
|
||||
aria-pressed={showInMenu}
|
||||
>
|
||||
<span className="setting-row-info">
|
||||
<span className="setting-row-title">Show in menu</span>
|
||||
<span className="setting-row-desc">
|
||||
Adds it to the end of the menu; saved with “Save Changes”.
|
||||
</span>
|
||||
</span>
|
||||
<span className={`switch-toggle switch-toggle--sm ${showInMenu ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</span>
|
||||
</button>
|
||||
<div className="confirm-actions">
|
||||
<button type="button" className="admin-btn admin-btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={!title.trim() || !effectiveSlug || !!collision || creating}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create page'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/admin/components/page-builder/PageContentEditor.tsx
Normal file
19
app/admin/components/page-builder/PageContentEditor.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { JournalEditor } from '../journal/JournalEditor';
|
||||
|
||||
/**
|
||||
* The journal's block editor and live preview in page mode (#722). Back goes
|
||||
* to Pages with the page selected again.
|
||||
*/
|
||||
export function PageContentEditor({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<JournalEditor
|
||||
slug={slug}
|
||||
kind="page"
|
||||
onBack={() => router.push(`/admin/pages?page=${encodeURIComponent(slug)}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
394
app/admin/components/page-builder/PagePanel.tsx
Normal file
394
app/admin/components/page-builder/PagePanel.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
parseJournalMarkdown,
|
||||
sanitizeSlug,
|
||||
serializeJournalMarkdown,
|
||||
slugFieldValue,
|
||||
type ParsedJournal,
|
||||
} from '@/lib/journal';
|
||||
import {
|
||||
describeCollision,
|
||||
pageSlugCollision,
|
||||
type PageSummary,
|
||||
type SlugTakenBy,
|
||||
} from '@/lib/pages';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import { IconFileText, IconTrash } from '../Icons';
|
||||
import { useConfirm } from '../ConfirmDialog';
|
||||
import { useNotify } from '../Notifications';
|
||||
import { reportIfSessionExpired } from '../sessionExpiry';
|
||||
import { useVersionedSave } from '../useVersionedSave';
|
||||
import type { GalleryVersionChange } from '@/lib/admin/pageRefs';
|
||||
|
||||
interface PagePanelProps {
|
||||
slug: string;
|
||||
/** Whether gallery.yaml (as edited in the builder) lists the page. */
|
||||
inMenu: boolean;
|
||||
onToggleMenu: () => void;
|
||||
/** Slugs a page may not take. */
|
||||
taken: SlugTakenBy;
|
||||
/** The other pages' slugs, which a rename must not take either. */
|
||||
otherPageSlugs: string[];
|
||||
/**
|
||||
* `galleryVersion` is set when the server rewrote gallery.yaml's menu, so
|
||||
* the builder can follow that change without mistaking it for a foreign
|
||||
* edit (#601).
|
||||
*/
|
||||
onSaved: (
|
||||
oldSlug: string,
|
||||
page: PageSummary,
|
||||
menuRenamed: boolean,
|
||||
galleryVersion?: GalleryVersionChange | null,
|
||||
) => void;
|
||||
onDeleted: (
|
||||
slug: string,
|
||||
removedFromMenu: boolean,
|
||||
galleryVersion?: GalleryVersionChange | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content page selected in the structure list (#722): its settings, saved
|
||||
* to content/pages/<slug>.md with the panel's own button. "Show in menu" is
|
||||
* the one exception — it edits gallery.yaml, so it goes with the builder's
|
||||
* Save like every other change to the menu.
|
||||
*/
|
||||
export default function PagePanel({
|
||||
slug,
|
||||
inMenu,
|
||||
onToggleMenu,
|
||||
taken,
|
||||
otherPageSlugs,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: PagePanelProps) {
|
||||
const confirm = useConfirm();
|
||||
const notify = useNotify();
|
||||
const [parsed, setParsed] = useState<ParsedJournal | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [slugInput, setSlugInput] = useState(slug);
|
||||
const [draft, setDraft] = useState(false);
|
||||
const [password, setPassword] = useState<string | undefined>(undefined);
|
||||
const [description, setDescription] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
/** The page file as loaded, sent back on save (#601). */
|
||||
const versionRef = useRef<string | null>(null);
|
||||
const versionedSave = useVersionedSave();
|
||||
/** Bumped to load the file again — "Reload" in the conflict prompt. */
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setParsed(null);
|
||||
setLoadError(null);
|
||||
fetch(`/api/admin/pages/${encodeURIComponent(slug)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
reportIfSessionExpired(res);
|
||||
throw new Error(
|
||||
res.status === 404
|
||||
? 'There is no file for this page in content/pages/.'
|
||||
: `The server answered ${res.status}.`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { page: { rawMarkdown: string }; version?: string }) => {
|
||||
if (cancelled) return;
|
||||
versionRef.current = data.version ?? null;
|
||||
const p = parseJournalMarkdown(data.page.rawMarkdown);
|
||||
setParsed(p);
|
||||
setTitle(p.frontmatter.title || '');
|
||||
setSlugInput(slug);
|
||||
setDraft(!!p.frontmatter.draft);
|
||||
setPassword(p.frontmatter.password || undefined);
|
||||
setDescription(p.frontmatter.description || '');
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setLoadError(err instanceof Error ? err.message : 'Could not load.');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug, reloadKey]);
|
||||
|
||||
// The field keeps what is typed, a trailing "-" included; the page moves to
|
||||
// the finished slug.
|
||||
const targetSlug = slugInput ? sanitizeSlug(slugInput) : '';
|
||||
|
||||
const fm = parsed?.frontmatter;
|
||||
const dirty =
|
||||
!!fm &&
|
||||
(title !== (fm.title || '') ||
|
||||
targetSlug !== slug ||
|
||||
draft !== !!fm.draft ||
|
||||
(password || '') !== (fm.password || '') ||
|
||||
description !== (fm.description || ''));
|
||||
|
||||
const renaming = targetSlug !== slug;
|
||||
const collision = renaming
|
||||
? otherPageSlugs.includes(targetSlug)
|
||||
? `A page "${targetSlug}" already exists.`
|
||||
: (() => {
|
||||
const c = pageSlugCollision(targetSlug, taken);
|
||||
return c ? describeCollision(targetSlug, c) : null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
async function save() {
|
||||
if (!parsed || !dirty || collision || !title.trim()) return;
|
||||
if (renaming) {
|
||||
const ok = await confirm({
|
||||
title: `Move this page to /${targetSlug}?`,
|
||||
message: `Links to /${slug} will stop working. There is no automatic redirect.`,
|
||||
confirmLabel: 'Move page',
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const markdown = serializeJournalMarkdown({
|
||||
...parsed,
|
||||
frontmatter: {
|
||||
...parsed.frontmatter,
|
||||
title: title.trim(),
|
||||
draft: draft || undefined,
|
||||
password: password || undefined,
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
});
|
||||
const result = await versionedSave(
|
||||
`/api/admin/pages/${encodeURIComponent(slug)}`,
|
||||
{ rawMarkdown: markdown, ...(renaming ? { newSlug: targetSlug } : {}) },
|
||||
versionRef,
|
||||
'This page',
|
||||
);
|
||||
if (result.kind === 'reload') {
|
||||
setReloadKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'keep') {
|
||||
notify('error', 'Not saved — the page changed elsewhere. Your edits are still here.');
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'failed') {
|
||||
if (!reportIfSessionExpired(result.res)) {
|
||||
notify('error', result.data?.error || 'Failed to save the page');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = result.data as {
|
||||
page: { slug: string; rawMarkdown: string };
|
||||
menuRenamed?: boolean;
|
||||
galleryVersion?: GalleryVersionChange | null;
|
||||
/** The page was saved, but its menu entry in gallery.yaml was not updated. */
|
||||
warning?: string;
|
||||
};
|
||||
const written = parseJournalMarkdown(data.page.rawMarkdown);
|
||||
setParsed(written);
|
||||
setPassword(written.frontmatter.password || undefined);
|
||||
const { title: t, description: d, password: pw, draft: dr } = written.frontmatter;
|
||||
onSaved(
|
||||
slug,
|
||||
{
|
||||
slug: data.page.slug,
|
||||
frontmatter: { title: t, description: d, password: pw, draft: dr },
|
||||
},
|
||||
data.menuRenamed === true,
|
||||
data.galleryVersion,
|
||||
);
|
||||
if (data.warning) notify('error', data.warning);
|
||||
else notify('success', 'Page saved.');
|
||||
} catch {
|
||||
notify('error', 'Could not save the page. Check the connection and try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const ok = await confirm({
|
||||
title: `Delete “${title || slug}”?`,
|
||||
message: inMenu
|
||||
? 'The page is also removed from the menu in gallery.yaml. Both files are backed up first and can be restored under Backups.'
|
||||
: 'A copy is kept and can be restored under Backups.',
|
||||
confirmLabel: 'Delete page',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/pages/${encodeURIComponent(slug)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
if (!reportIfSessionExpired(res))
|
||||
notify('error', data?.error || 'Failed to delete the page');
|
||||
return;
|
||||
}
|
||||
onDeleted(slug, data?.removedFromMenu === true, data?.galleryVersion);
|
||||
if (typeof data?.warning === 'string') notify('error', data.warning);
|
||||
else notify('success', 'Page deleted.');
|
||||
} catch {
|
||||
notify('error', 'Could not delete the page. Check the connection and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<section className="builder-section">
|
||||
<div className="builder-section-header">
|
||||
<h2>
|
||||
<IconFileText /> /{slug}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-error" role="alert">
|
||||
<strong>This page could not be loaded.</strong> {loadError}
|
||||
{inMenu && (
|
||||
<p>
|
||||
The menu still lists it. Switch off “Show in menu” and save the structure to remove
|
||||
the entry.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{inMenu && (
|
||||
<button type="button" className="admin-btn admin-btn-secondary" onClick={onToggleMenu}>
|
||||
Remove from menu
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="builder-section page-panel">
|
||||
<div className="builder-section-header">
|
||||
<h2>
|
||||
<IconFileText /> {fm?.title || slug}
|
||||
</h2>
|
||||
<a
|
||||
href={`/${encodeURIComponent(slug)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
title="Open the live page in a new tab"
|
||||
>
|
||||
/{slug} ↗
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="page-title">Title</label>
|
||||
<input id="page-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="page-slug">URL</label>
|
||||
<div className="input-slug-wrapper">
|
||||
<input
|
||||
id="page-slug"
|
||||
className="subpage-name-input"
|
||||
value={slugInput}
|
||||
onChange={(e) => setSlugInput(slugFieldValue(e.target.value))}
|
||||
aria-describedby="page-slug-hint"
|
||||
/>
|
||||
<span className="input-slug-preview">/{targetSlug}</span>
|
||||
</div>
|
||||
<span
|
||||
id="page-slug-hint"
|
||||
className={`admin-field-hint${collision ? ' admin-field-hint--error' : renaming ? ' admin-field-hint--warn' : ''}`}
|
||||
>
|
||||
{collision
|
||||
? collision
|
||||
: renaming
|
||||
? `Links to /${slug} will stop working. There is no automatic redirect.`
|
||||
: 'Checked against subpages, albums, journal entries and built-in routes.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="page-panel-switches">
|
||||
<button type="button" className="setting-row" onClick={onToggleMenu} aria-pressed={inMenu}>
|
||||
<span className="setting-row-info">
|
||||
<span className="setting-row-title">Show in menu</span>
|
||||
<span className="setting-row-desc">
|
||||
{inMenu
|
||||
? 'Listed in the header menu. Drag it in the structure list to move it.'
|
||||
: 'Not in the menu, but reachable at its URL — for pages shared by link.'}{' '}
|
||||
Saved with “Save Changes”.
|
||||
</span>
|
||||
</span>
|
||||
<span className={`switch-toggle switch-toggle--sm ${inMenu ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="setting-row"
|
||||
onClick={() => setDraft(!draft)}
|
||||
aria-pressed={draft}
|
||||
>
|
||||
<span className="setting-row-info">
|
||||
<span className="setting-row-title">Draft</span>
|
||||
<span className="setting-row-desc">
|
||||
Only you can see a draft. It stays out of the menu and the sitemap.
|
||||
</span>
|
||||
</span>
|
||||
<span className={`switch-toggle switch-toggle--sm ${draft ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<span className="admin-field-label">Password (optional)</span>
|
||||
<PasswordField value={password} onChange={setPassword} label="Page password" />
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="page-description">SEO description</label>
|
||||
<textarea
|
||||
id="page-description"
|
||||
rows={2}
|
||||
value={description}
|
||||
placeholder="One or two sentences for search engines and link previews"
|
||||
onChange={(e) => setDescription(e.target.value.replace(/\s*\n\s*/g, ' '))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="page-panel-actions">
|
||||
<Link href={`/admin/pages/edit/${slug}`} className="admin-btn admin-btn-secondary">
|
||||
Edit content →
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={save}
|
||||
disabled={!dirty || saving || !!collision || !title.trim()}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save page'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger"
|
||||
onClick={remove}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
<IconTrash size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
*/
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconFileText, IconFolder, IconGripVertical, IconLock, IconPencil, IconX } from '../Icons';
|
||||
import type { Subpage } from './types';
|
||||
|
||||
// ── Sortable Hero Tile ─────────────────────────────────────────
|
||||
@@ -211,3 +213,79 @@ export function SortableSubpageRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sortable Page Row (#722) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* A content page in the structure list: document icon and a PAGE tag, so it
|
||||
* reads as something other than a subpage. The same row sits in the menu
|
||||
* list and in "Not in menu"; `id` says which, for the drag handler.
|
||||
*/
|
||||
export function SortablePageRow({
|
||||
id,
|
||||
title,
|
||||
isActive,
|
||||
draft,
|
||||
hasPassword,
|
||||
missing,
|
||||
onClick,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
draft?: boolean;
|
||||
hasPassword?: boolean;
|
||||
/** Referenced from gallery.yaml, but there is no file for it. */
|
||||
missing?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}}
|
||||
className={`pb-row ${isActive ? 'active' : ''} ${draft || missing ? 'is-off' : ''}`}
|
||||
{...attributes}
|
||||
>
|
||||
<span className="pb-row-grip" {...listeners} title="Drag to reorder">
|
||||
<IconGripVertical size={14} />
|
||||
</span>
|
||||
<button type="button" className="pb-row-main" onClick={onClick} aria-current={isActive}>
|
||||
<span className="pb-row-thumb">
|
||||
<IconFileText size={14} />
|
||||
</span>
|
||||
<span className="pb-row-name">
|
||||
{title}
|
||||
{hasPassword && <IconLock size={11} />}
|
||||
</span>
|
||||
{missing ? (
|
||||
<span className="pb-row-state">Missing</span>
|
||||
) : draft ? (
|
||||
<span className="pb-row-state">Draft</span>
|
||||
) : (
|
||||
<span className="pb-row-tag">Page</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Not in menu" group as a drop target, so a page dragged out of the
|
||||
* menu lands there even when the group is empty (#722).
|
||||
*/
|
||||
export function OffMenuDropZone({ id, children }: { id: string; children: ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
return (
|
||||
<div ref={setNodeRef} className={`pb-offmenu ${isOver ? 'is-over' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,15 @@ export const addHero = (g: GalleryState, assetId: string): GalleryState => ({
|
||||
hero: [...g.hero, assetId],
|
||||
});
|
||||
|
||||
/** Append several hero photos in pick order, skipping any already listed. */
|
||||
export const addHeroes = (g: GalleryState, assetIds: string[]): GalleryState => ({
|
||||
...g,
|
||||
hero: [
|
||||
...g.hero,
|
||||
...assetIds.filter((id, i) => !g.hero.includes(id) && assetIds.indexOf(id) === i),
|
||||
],
|
||||
});
|
||||
|
||||
export const removeHero = (g: GalleryState, index: number): GalleryState => ({
|
||||
...g,
|
||||
hero: g.hero.filter((_, i) => i !== index),
|
||||
@@ -113,6 +122,14 @@ export const addSubpage = (g: GalleryState): GalleryState => ({
|
||||
export const removeSubpage = (g: GalleryState, index: number): GalleryState => ({
|
||||
...g,
|
||||
subpages: g.subpages.filter((_, i) => i !== index),
|
||||
// A page after the removed subpage moves up with the rest of the menu.
|
||||
...(g.pageRefs
|
||||
? {
|
||||
pageRefs: g.pageRefs.map((r) =>
|
||||
r.position > index ? { ...r, position: r.position - 1 } : r,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
export const updateSubpage = (g: GalleryState, index: number, updates: Partial<Subpage>) =>
|
||||
@@ -142,6 +159,98 @@ export const updateSection = (
|
||||
updates: Partial<Section>,
|
||||
) => mapSection(g, subpageIndex, sectionIndex, (sec) => ({ ...sec, ...updates }));
|
||||
|
||||
// ── Menu: subpages and content pages together (#722) ────────────
|
||||
|
||||
/** One row of the menu list: a subpage by its index, or a page by its slug. */
|
||||
export type MenuItem = { kind: 'subpage'; index: number } | { kind: 'page'; slug: string };
|
||||
|
||||
/** The menu in the order it is saved and shown: subpages with pages interleaved. */
|
||||
export function menuItems(g: GalleryState): MenuItem[] {
|
||||
const refs = g.pageRefs ?? [];
|
||||
const out: MenuItem[] = [];
|
||||
for (let i = 0; i <= g.subpages.length; i++) {
|
||||
for (const ref of refs) {
|
||||
if (Math.min(ref.position, g.subpages.length) === i)
|
||||
out.push({ kind: 'page', slug: ref.slug });
|
||||
}
|
||||
if (i < g.subpages.length) out.push({ kind: 'subpage', index: i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rebuild the state from a reordered menu list. */
|
||||
function fromMenu(g: GalleryState, items: MenuItem[]): GalleryState {
|
||||
const subpages: GalleryState['subpages'] = [];
|
||||
const pageRefs: NonNullable<GalleryState['pageRefs']> = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === 'subpage') subpages.push(g.subpages[item.index]);
|
||||
else pageRefs.push({ slug: item.slug, position: subpages.length });
|
||||
}
|
||||
return { ...g, subpages, pageRefs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a menu row from one position to another. Subpage indexes change with
|
||||
* it; `followMenuMove` tells the builder where an open subpage went.
|
||||
*/
|
||||
export function moveMenuItem(g: GalleryState, from: number, to: number): GalleryState {
|
||||
const items = menuItems(g);
|
||||
if (!items[from] || !items[to]) return g;
|
||||
return fromMenu(g, arrayMove(items, from, to));
|
||||
}
|
||||
|
||||
/** The index an open subpage has after moveMenuItem(from, to). */
|
||||
export function followMenuMove(
|
||||
g: GalleryState,
|
||||
open: number | null,
|
||||
from: number,
|
||||
to: number,
|
||||
): number | null {
|
||||
if (open === null) return null;
|
||||
const moved = arrayMove(menuItems(g), from, to);
|
||||
let index = 0;
|
||||
for (const item of moved) {
|
||||
if (item.kind !== 'subpage') continue;
|
||||
if (item.index === open) return index;
|
||||
index++;
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
export function isPageInMenu(g: GalleryState, slug: string): boolean {
|
||||
return (g.pageRefs ?? []).some((r) => r.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a page in the menu or take it out. `at` is a menu row index to insert
|
||||
* before; without one the page goes to the end of the menu.
|
||||
*/
|
||||
export function setPageInMenu(
|
||||
g: GalleryState,
|
||||
slug: string,
|
||||
inMenu: boolean,
|
||||
at?: number,
|
||||
): GalleryState {
|
||||
const without = (g.pageRefs ?? []).filter((r) => r.slug !== slug);
|
||||
if (!inMenu) {
|
||||
return isPageInMenu(g, slug) ? { ...g, pageRefs: without } : g;
|
||||
}
|
||||
if (isPageInMenu(g, slug)) return g;
|
||||
const items = menuItems(g);
|
||||
const index = at === undefined ? items.length : Math.max(0, Math.min(at, items.length));
|
||||
items.splice(index, 0, { kind: 'page', slug });
|
||||
return fromMenu(g, items);
|
||||
}
|
||||
|
||||
/** Follow a page rename, keeping its place in the menu. */
|
||||
export function renamePageRef(g: GalleryState, from: string, to: string): GalleryState {
|
||||
if (!isPageInMenu(g, from)) return g;
|
||||
return {
|
||||
...g,
|
||||
pageRefs: (g.pageRefs ?? []).map((r) => (r.slug === from ? { ...r, slug: to } : r)),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Reading ─────────────────────────────────────────────────────
|
||||
|
||||
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */
|
||||
|
||||
@@ -14,6 +14,22 @@ export interface GalleryState {
|
||||
hero: string[];
|
||||
albums: AlbumEntry[];
|
||||
subpages: Subpage[];
|
||||
/**
|
||||
* Content pages in the menu (#722): `- page: <slug>` among the subpages.
|
||||
* Optional so a draft saved before pages existed still loads.
|
||||
*/
|
||||
pageRefs?: PageRef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A content page placed in the menu. `position` is the number of subpages in
|
||||
* front of it, so the subpage list keeps its own indexes — every drawer and
|
||||
* album address in the builder is one — and the menu order is rebuilt from
|
||||
* both lists when saving. Refs sharing a position keep their array order.
|
||||
*/
|
||||
export interface PageRef {
|
||||
slug: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,8 +43,18 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
|
||||
);
|
||||
|
||||
let subpages: Subpage[] = [];
|
||||
const pageRefs: PageRef[] = [];
|
||||
if (Array.isArray(raw.subpages)) {
|
||||
subpages = (raw.subpages as Array<Record<string, unknown>>).map((sp) => ({
|
||||
const entries = raw.subpages as Array<Record<string, unknown>>;
|
||||
const subpageEntries: Array<Record<string, unknown>> = [];
|
||||
for (const entry of entries) {
|
||||
if (entry && typeof entry.page === 'string') {
|
||||
pageRefs.push({ slug: entry.page, position: subpageEntries.length });
|
||||
} else {
|
||||
subpageEntries.push(entry);
|
||||
}
|
||||
}
|
||||
subpages = subpageEntries.map((sp) => ({
|
||||
name: (sp.name as string) || '',
|
||||
title: sp.title as string | undefined,
|
||||
subtitle: sp.subtitle as string | undefined,
|
||||
@@ -77,7 +103,7 @@ export function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
|
||||
});
|
||||
}
|
||||
|
||||
return { hero, albums, subpages };
|
||||
return { hero, albums, subpages, pageRefs };
|
||||
}
|
||||
|
||||
/** The gallery.yaml object for a builder state. Empty groups are left out. */
|
||||
@@ -90,8 +116,9 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
|
||||
if (gallery.albums.length > 0) {
|
||||
yamlData.albums = serializeAlbumEntries(gallery.albums);
|
||||
}
|
||||
if (gallery.subpages.length > 0) {
|
||||
yamlData.subpages = gallery.subpages.map((sp) => {
|
||||
const pageRefs = gallery.pageRefs ?? [];
|
||||
if (gallery.subpages.length > 0 || pageRefs.length > 0) {
|
||||
const subpageEntries = gallery.subpages.map((sp) => {
|
||||
const entry: Record<string, unknown> = { name: sp.name };
|
||||
if (sp.title) entry.title = sp.title;
|
||||
if (sp.subtitle) entry.subtitle = sp.subtitle;
|
||||
@@ -124,7 +151,25 @@ export function serializeGallery(gallery: GalleryState): Record<string, unknown>
|
||||
|
||||
return entry;
|
||||
});
|
||||
yamlData.subpages = interleave(subpageEntries, pageRefs);
|
||||
}
|
||||
|
||||
return yamlData;
|
||||
}
|
||||
|
||||
/** Subpage entries with `{ page }` entries placed at their positions. */
|
||||
function interleave(
|
||||
subpageEntries: Record<string, unknown>[],
|
||||
pageRefs: PageRef[],
|
||||
): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i <= subpageEntries.length; i++) {
|
||||
// A ref beyond the end (a subpage was removed) lands at the end.
|
||||
for (const ref of pageRefs) {
|
||||
const at = Math.min(ref.position, subpageEntries.length);
|
||||
if (at === i) out.push({ page: ref.slug });
|
||||
}
|
||||
if (i < subpageEntries.length) out.push(subpageEntries[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -111,7 +111,10 @@ export interface PickerTarget {
|
||||
|
||||
export interface HeroPickerTarget {
|
||||
albumId?: string;
|
||||
onSelect: (assetId: string) => void;
|
||||
/** Single pick; the picker closes after it. */
|
||||
onSelect?: (assetId: string) => void;
|
||||
/** Multi-select: several picks confirmed at once (#602). */
|
||||
onSelectMany?: (assetIds: string[]) => void;
|
||||
currentAssetIds?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Icons from '../Icons';
|
||||
import type { SaveStatus } from '../SaveBar';
|
||||
import { useDraft, readDraft } from '../useDraft';
|
||||
import { useContentRestored } from '../contentRestored';
|
||||
import { useVersionedSave } from '../useVersionedSave';
|
||||
import { useLatest } from '../useLatest';
|
||||
|
||||
interface AboutMeta {
|
||||
portrait?: string;
|
||||
@@ -54,6 +56,8 @@ export function useAboutEditor(active: boolean) {
|
||||
const [aboutStatus, setAboutStatus] = useState<SaveStatus>(null);
|
||||
const [aboutGearText, setAboutGearText] = useState('');
|
||||
const [serverAbout, setServerAbout] = useState<AboutDraft | null>(null);
|
||||
/** The fields as last rendered: tell a finished save whether editing went on meanwhile. */
|
||||
const latest = useLatest({ meta: aboutMeta, body: aboutBody, gearText: aboutGearText });
|
||||
|
||||
// Unsaved About edits survive a tab switch or Reload, like settings (#592).
|
||||
const draft = useDraft<AboutDraft>(
|
||||
@@ -62,6 +66,9 @@ export function useAboutEditor(active: boolean) {
|
||||
aboutDirty,
|
||||
);
|
||||
const loadDraft = draft.load;
|
||||
/** about.md as loaded, sent back on save so a change elsewhere is caught (#601). */
|
||||
const versionRef = useRef<string | null>(null);
|
||||
const versionedSave = useVersionedSave();
|
||||
|
||||
const applyAbout = (value: AboutDraft) => {
|
||||
setAboutMeta(value.meta);
|
||||
@@ -82,6 +89,7 @@ export function useAboutEditor(active: boolean) {
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
versionRef.current = typeof data.version === 'string' ? data.version : null;
|
||||
const loaded: AboutDraft = {
|
||||
meta: data.meta || {},
|
||||
body: data.body || '',
|
||||
@@ -128,22 +136,37 @@ export function useAboutEditor(active: boolean) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/about', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const result = await versionedSave(
|
||||
'/api/admin/about',
|
||||
{ meta: cleanedMeta, body: aboutBody },
|
||||
versionRef,
|
||||
'The About page',
|
||||
);
|
||||
if (result.kind === 'reload') {
|
||||
draft.discard();
|
||||
await loadAboutContent();
|
||||
} else if (result.kind === 'keep') {
|
||||
setAboutStatus({ kind: 'error', message: 'Not saved — the About page changed elsewhere.' });
|
||||
} else if (result.kind === 'saved') {
|
||||
const data = result.data;
|
||||
const saved: AboutDraft = { meta: cleanedMeta, body: aboutBody, gearText: aboutGearText };
|
||||
// Edited while the request was out: those edits are not saved yet.
|
||||
const now = latest.current;
|
||||
const editedMeanwhile =
|
||||
now.meta !== aboutMeta || now.body !== aboutBody || now.gearText !== aboutGearText;
|
||||
setServerAbout(saved);
|
||||
draft.saved(aboutFingerprint(saved));
|
||||
setAboutDirty(false);
|
||||
setAboutStatus({ kind: 'success', message: data.message || 'Saved!' });
|
||||
draft.saved(aboutFingerprint(saved), editedMeanwhile);
|
||||
if (!editedMeanwhile) setAboutDirty(false);
|
||||
setAboutStatus({
|
||||
kind: 'success',
|
||||
message: typeof data.message === 'string' ? data.message : 'Saved!',
|
||||
});
|
||||
setTimeout(() => setAboutStatus(null), 4000);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
setAboutStatus({ kind: 'error', message: `Error: ${err.error}` });
|
||||
setAboutStatus({
|
||||
kind: 'error',
|
||||
message: `Error: ${result.data?.error ?? `HTTP ${result.res.status}`}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setAboutStatus({ kind: 'error', message: 'Error: Failed to save' });
|
||||
|
||||
@@ -137,6 +137,23 @@ export default function GeneralSection({ settings, update, updateMany }: Section
|
||||
onToggle={() => update('proofing.enabled', settings.proofing?.enabled === false)}
|
||||
/>
|
||||
</div>
|
||||
{/* Shown even while proofing is off globally: a subpage can switch it
|
||||
on for itself (`proofing: true`), so the address still has to be
|
||||
settable from here. */}
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proofing-email">Proofing email</label>
|
||||
<input
|
||||
id="proofing-email"
|
||||
type="email"
|
||||
value={settings.proofing?.email || ''}
|
||||
onChange={(e) => update('proofing.email', e.target.value)}
|
||||
placeholder="Defaults to the footer contact email"
|
||||
/>
|
||||
<p className="admin-field-hint">
|
||||
Where “Email to photographer” sends a selection. Falls back to the footer
|
||||
contact email; when neither is set, the button is hidden.
|
||||
</p>
|
||||
</div>
|
||||
</FeatureGroup>
|
||||
|
||||
<FeatureGroup
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PHOTO_GRID_COLUMNS_MIN,
|
||||
PHOTO_GRID_GAP_MAX,
|
||||
} from '@/lib/config/schema';
|
||||
import { FieldError } from './fields';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'justified'];
|
||||
@@ -131,7 +132,7 @@ function AspectRatioPreview({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function GridSection({ settings, update }: SectionProps) {
|
||||
export default function GridSection({ settings, update, fieldErrors }: SectionProps) {
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
@@ -185,7 +186,10 @@ export default function GridSection({ settings, update }: SectionProps) {
|
||||
max={PHOTO_GRID_COLUMNS_MAX}
|
||||
value={settings.grid?.columns ?? 3}
|
||||
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
|
||||
aria-invalid={fieldErrors?.['grid.columns'] ? true : undefined}
|
||||
aria-describedby={fieldErrors?.['grid.columns'] ? 'grid-columns-error' : undefined}
|
||||
/>
|
||||
<FieldError id="grid-columns-error" message={fieldErrors?.['grid.columns']} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="grid-gap-spacing">Gap Spacing (px)</label>
|
||||
@@ -196,7 +200,10 @@ export default function GridSection({ settings, update }: SectionProps) {
|
||||
max={PHOTO_GRID_GAP_MAX}
|
||||
value={settings.grid?.gap ?? 12}
|
||||
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
|
||||
aria-invalid={fieldErrors?.['grid.gap'] ? true : undefined}
|
||||
aria-describedby={fieldErrors?.['grid.gap'] ? 'grid-gap-error' : undefined}
|
||||
/>
|
||||
<FieldError id="grid-gap-error" message={fieldErrors?.['grid.gap']} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import * as Icons from '../Icons';
|
||||
import ToggleCard from '../fields/ToggleCard';
|
||||
import PrivacyEditor from '../PrivacyEditor';
|
||||
import EnvLockNote from '../fields/EnvLockNote';
|
||||
import { CONTACT_RETENTION_DEFAULT, CONTACT_RETENTION_MAX, isHttpUrl } from '@/lib/config/schema';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export default function LegalSection({ settings, update }: SectionProps) {
|
||||
export default function LegalSection({ settings, update, envLocks }: SectionProps) {
|
||||
const notifyLock = envLocks?.['contact.notifyUrl'];
|
||||
// The Impressum drops a non-http(s) contact URL with a warning in the server
|
||||
// log, where nobody looks; say it here instead.
|
||||
const contactUrl = settings.legal?.contactUrl?.trim();
|
||||
@@ -206,16 +208,19 @@ export default function LegalSection({ settings, update }: SectionProps) {
|
||||
value={settings.contact?.notifyUrl || ''}
|
||||
onChange={(e) => update('contact.notifyUrl', e.target.value)}
|
||||
placeholder="https://ntfy.sh/your-secret-topic"
|
||||
aria-invalid={notifyUrlInvalid || undefined}
|
||||
aria-describedby="contact-notify-url-hint"
|
||||
aria-invalid={(!notifyLock && notifyUrlInvalid) || undefined}
|
||||
aria-describedby={notifyLock ? 'contact-notify-url-lock' : 'contact-notify-url-hint'}
|
||||
disabled={!!notifyLock}
|
||||
/>
|
||||
{notifyLock && <EnvLockNote id="contact-notify-url-lock" variable={notifyLock} />}
|
||||
<p
|
||||
id="contact-notify-url-hint"
|
||||
className={`admin-field-hint${notifyUrlInvalid ? ' admin-field-hint--error' : ''}`}
|
||||
>
|
||||
{notifyUrlInvalid
|
||||
? 'Must start with https:// or http://.'
|
||||
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess. CONTACT_NOTIFY_URL overrides this.'}
|
||||
: 'Gets a fixed "new message" push, with nothing about the sender. Pick a topic name nobody can guess.' +
|
||||
(notifyLock ? '' : ' CONTACT_NOTIFY_URL overrides this.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import * as Icons from '../Icons';
|
||||
import ToggleCard from '../fields/ToggleCard';
|
||||
import PasswordField from '../fields/PasswordField';
|
||||
import EnvLockNote from '../fields/EnvLockNote';
|
||||
import { resolveWatermarkOpacity } from '@/lib/config/schema';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export default function SecuritySection({ settings, update }: SectionProps) {
|
||||
export default function SecuritySection({ settings, update, envLocks }: SectionProps) {
|
||||
const passwordLock = envLocks?.sitePassword;
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
@@ -27,11 +29,18 @@ export default function SecuritySection({ settings, update }: SectionProps) {
|
||||
onChange={(password) => update('sitePassword', password ?? '')}
|
||||
placeholder="Leave empty for a public site"
|
||||
label="Site password"
|
||||
disabled={!!passwordLock}
|
||||
/>
|
||||
{passwordLock && <EnvLockNote variable={passwordLock} />}
|
||||
<p className="admin-field-hint">
|
||||
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed. The{' '}
|
||||
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin panel
|
||||
keeps its own password and is never behind this gate.
|
||||
Stored as a <code>scrypt:…</code> hash in <code>settings.yaml</code>, never as typed.
|
||||
{!passwordLock && (
|
||||
<>
|
||||
{' '}
|
||||
The <code>SITE_PASSWORD</code> environment variable overrides this field.
|
||||
</>
|
||||
)}{' '}
|
||||
The admin panel keeps its own password and is never behind this gate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import * as Icons from '../Icons';
|
||||
import ToggleCard from '../fields/ToggleCard';
|
||||
import { FieldError } from './fields';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export default function SeoSection({
|
||||
settings,
|
||||
update,
|
||||
fieldErrors,
|
||||
siteUrlInfo,
|
||||
}: SectionProps & {
|
||||
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
|
||||
@@ -52,7 +54,10 @@ export default function SeoSection({
|
||||
value={settings.url || ''}
|
||||
onChange={(e) => update('url', e.target.value)}
|
||||
placeholder="https://folio.example"
|
||||
aria-invalid={fieldErrors?.url ? true : undefined}
|
||||
aria-describedby={fieldErrors?.url ? 'seo-site-url-error' : undefined}
|
||||
/>
|
||||
<FieldError id="seo-site-url-error" message={fieldErrors?.url} />
|
||||
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
|
||||
Needed for <code>sitemap.xml</code>, the feed and structured data — those are generated
|
||||
without a request, so the address cannot be derived from it. Leave empty and the sitemap
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, type CSSProperties } from 'react';
|
||||
import * as Icons from '../Icons';
|
||||
import OptionGrid, { toOptions } from '../fields/OptionGrid';
|
||||
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
import { effectiveTheme, FeatureRow } from './fields';
|
||||
import { effectiveTheme, FeatureRow, FieldError } from './fields';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
const PRESETS = ['studio-modern', 'studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
@@ -264,7 +264,7 @@ function PresetSpecs({ value }: { value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThemeSection({ settings, update }: SectionProps) {
|
||||
export default function ThemeSection({ settings, update, fieldErrors }: SectionProps) {
|
||||
// Read off the page on mount: the toggle previews light and dark on the
|
||||
// admin itself, and the page may already be in either.
|
||||
const [currentMode, setCurrentMode] = useState<'dark' | 'light'>(() =>
|
||||
@@ -417,8 +417,11 @@ export default function ThemeSection({ settings, update }: SectionProps) {
|
||||
value={settings.theme?.accent || ''}
|
||||
onChange={(e) => update('theme.accent', e.target.value)}
|
||||
placeholder={theme.accent}
|
||||
aria-invalid={fieldErrors?.['theme.accent'] ? true : undefined}
|
||||
aria-describedby={fieldErrors?.['theme.accent'] ? 'theme-accent-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
<FieldError id="theme-accent-error" message={fieldErrors?.['theme.accent']} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,20 @@ import { resolveTheme } from '@/lib/config/theme';
|
||||
import type { SettingsYaml } from '@/lib/config/schema';
|
||||
import type { Settings } from './types';
|
||||
|
||||
/**
|
||||
* The message under an input whose value the site would ignore (`fieldErrors`).
|
||||
* Not a live region: it changes as you type, and announcing every keystroke
|
||||
* would drown the field itself. The input points at it with aria-describedby.
|
||||
*/
|
||||
export function FieldError({ id, message }: { id: string; message?: string }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p id={id} className="admin-field-hint admin-field-hint--error">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled block of related switches.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { EnvLocks } from '@/lib/admin/envLocks';
|
||||
|
||||
/**
|
||||
* Shared shape of the settings form (#554). The sections edit one object
|
||||
* that mirrors settings.yaml; SettingsEditor owns it and hands each section
|
||||
@@ -76,6 +78,7 @@ export interface Settings {
|
||||
proofing?: {
|
||||
enabled?: boolean;
|
||||
allowMailto?: boolean;
|
||||
email?: string;
|
||||
};
|
||||
watermark?: {
|
||||
enabled?: boolean;
|
||||
@@ -92,4 +95,12 @@ export interface SectionProps {
|
||||
update: (path: string, value: unknown) => void;
|
||||
/** Set several paths in one state update. */
|
||||
updateMany: (entries: Record<string, unknown>) => void;
|
||||
/** Fields an environment variable overrides; rendered locked (#605). */
|
||||
envLocks?: EnvLocks;
|
||||
/**
|
||||
* Dotted path → message, for values the site would ignore (the save route
|
||||
* rejects them). Checked as the form changes, so the message stands next to
|
||||
* the input before a save is tried.
|
||||
*/
|
||||
fieldErrors?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { DoctorLevel } from '@/lib/admin/doctor';
|
||||
*/
|
||||
export const DOCTOR_LEVEL_EVENT = 'folio:doctor-level';
|
||||
|
||||
/** The three tones `status-badge-btn` is styled for. */
|
||||
export type BadgeTone = 'connected' | 'disconnected' | 'unknown';
|
||||
/** The tones `status-badge-btn` is styled for. */
|
||||
export type BadgeTone = 'connected' | 'warning' | 'disconnected' | 'unknown';
|
||||
|
||||
export interface SystemHealthInput {
|
||||
statusLoading: boolean;
|
||||
@@ -49,7 +49,9 @@ export function systemHealth({
|
||||
if (setupIncomplete) return { tone: 'unknown', label: 'Setup Incomplete' };
|
||||
if (immich === 'error') return { tone: 'disconnected', label: 'System Degraded' };
|
||||
if (doctorLevel === 'error') return { tone: 'disconnected', label: 'Needs Attention' };
|
||||
if (doctorLevel === 'warn') return { tone: 'unknown', label: 'Check Diagnostics' };
|
||||
// Amber, like the warnings on the diagnostics page it points to. Grey read
|
||||
// as "no answer", which is what `unknown` means.
|
||||
if (doctorLevel === 'warn') return { tone: 'warning', label: 'Check Diagnostics' };
|
||||
if (immich === 'ok') return { tone: 'connected', label: 'System OK' };
|
||||
return { tone: 'unknown', label: 'Status Unknown' };
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
||||
*/
|
||||
const holdRef = useRef(false);
|
||||
const [status, setStatus] = useState<DraftStatus>('none');
|
||||
/** The value as last rendered, for `saved()` when edits arrived during the save. */
|
||||
const valueRef = useRef(value);
|
||||
useEffect(() => {
|
||||
valueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
// Mirror the edits while there are any, and drop the draft once there are
|
||||
// none — a save or a discard. Nothing is written before `load()` has set a
|
||||
@@ -142,12 +147,19 @@ export function useDraft<T>(key: string, value: T, dirty: boolean) {
|
||||
[key],
|
||||
);
|
||||
|
||||
/** Call after a successful save, with the fingerprint of what was saved. */
|
||||
/**
|
||||
* Call after a successful save, with the fingerprint of what was saved.
|
||||
* `stillDirty` when the editor was changed while the save was in flight: those
|
||||
* edits stay a draft, now on top of the version just written. Clearing it
|
||||
* would leave them unkept until the next keystroke, since the mirror effect
|
||||
* above only runs when the value or the dirty flag changes.
|
||||
*/
|
||||
const saved = useCallback(
|
||||
(base: string) => {
|
||||
(base: string, stillDirty = false) => {
|
||||
baseRef.current = base;
|
||||
holdRef.current = false;
|
||||
clearDraft(key);
|
||||
if (stillDirty) writeDraft(key, { base, value: valueRef.current });
|
||||
else clearDraft(key);
|
||||
setStatus('none');
|
||||
},
|
||||
[key],
|
||||
|
||||
20
app/admin/components/useLatest.ts
Normal file
20
app/admin/components/useLatest.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* A ref that always holds the value of the latest render.
|
||||
*
|
||||
* For code that awaits a request and then has to know whether the editor moved
|
||||
* on meanwhile: a save handler closes over the state it started with, so after
|
||||
* the `await` it cannot tell "nothing changed" from "the operator kept typing".
|
||||
* Comparing what was sent with `ref.current` can (every edit replaces the state
|
||||
* value, so identity is enough).
|
||||
*/
|
||||
export function useLatest<T>(value: T) {
|
||||
const ref = useRef(value);
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref;
|
||||
}
|
||||
97
app/admin/components/useVersionedSave.ts
Normal file
97
app/admin/components/useVersionedSave.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Saving a content file without clobbering a change made elsewhere (#601).
|
||||
*
|
||||
* Each editor keeps the `version` its GET returned in a ref and saves through
|
||||
* this hook, which sends it as `If-Match`. When the server answers 409 with
|
||||
* `conflict: true` — the file was saved from another tab or device, edited by
|
||||
* hand, or restored from a backup since it was loaded — the operator picks:
|
||||
*
|
||||
* - Reload: throw away this editor's edits and load the file as it is now.
|
||||
* - Overwrite anyway: save again against the new version, replacing that change.
|
||||
* - Keep editing (cancel): nothing happens; the edits and their draft stay.
|
||||
*
|
||||
* Other 409s (a rename onto a taken slug) carry no `conflict` flag and are
|
||||
* returned like any other failure.
|
||||
*/
|
||||
|
||||
import { useCallback, type MutableRefObject } from 'react';
|
||||
import { useChoice } from './ConfirmDialog';
|
||||
|
||||
/** The If-Match header for a version, or none before the first load. */
|
||||
export function ifMatch(version: string | null): Record<string, string> {
|
||||
return version ? { 'If-Match': `"${version}"` } : {};
|
||||
}
|
||||
|
||||
/** The body of any JSON answer; routes return objects. */
|
||||
export type SaveData = Record<string, unknown> & { error?: string };
|
||||
|
||||
export type VersionedSaveResult =
|
||||
| { kind: 'saved'; res: Response; data: SaveData }
|
||||
| { kind: 'failed'; res: Response; data: SaveData | null }
|
||||
| { kind: 'reload' }
|
||||
| { kind: 'keep' };
|
||||
|
||||
export function isEditConflict(res: Response, data: unknown): data is { version: string } {
|
||||
return (
|
||||
res.status === 409 &&
|
||||
!!data &&
|
||||
typeof data === 'object' &&
|
||||
(data as { conflict?: unknown }).conflict === true &&
|
||||
typeof (data as { version?: unknown }).version === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
type ConflictChoice = 'overwrite' | 'reload';
|
||||
|
||||
/**
|
||||
* `what` names the file in the dialog ("The settings", "This entry").
|
||||
* A network failure throws, as a plain fetch would.
|
||||
*/
|
||||
export function useVersionedSave() {
|
||||
const choose = useChoice();
|
||||
|
||||
return useCallback(
|
||||
async (
|
||||
url: string,
|
||||
body: unknown,
|
||||
versionRef: MutableRefObject<string | null>,
|
||||
what: string,
|
||||
): Promise<VersionedSaveResult> => {
|
||||
let version = versionRef.current;
|
||||
for (;;) {
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...ifMatch(version) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as SaveData | null;
|
||||
|
||||
if (isEditConflict(res, data)) {
|
||||
const choice = await choose<ConflictChoice>({
|
||||
title: 'Changed elsewhere',
|
||||
message: `${what} was changed since you opened it — in another tab, on another device, by hand or by a backup restore. Saving now would replace that change.`,
|
||||
actions: [
|
||||
{ id: 'overwrite', label: 'Overwrite anyway' },
|
||||
{ id: 'reload', label: 'Reload (discard mine)' },
|
||||
],
|
||||
cancelLabel: 'Keep editing',
|
||||
});
|
||||
if (choice === 'overwrite') {
|
||||
version = data.version;
|
||||
continue;
|
||||
}
|
||||
return { kind: choice === 'reload' ? 'reload' : 'keep' };
|
||||
}
|
||||
|
||||
if (res.ok && data) {
|
||||
if (typeof data.version === 'string') versionRef.current = data.version;
|
||||
return { kind: 'saved', res, data };
|
||||
}
|
||||
return { kind: 'failed', res, data };
|
||||
}
|
||||
},
|
||||
[choose],
|
||||
);
|
||||
}
|
||||
@@ -16,9 +16,15 @@ export const metadata: Metadata = {
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* `lang="en"`: `<html lang>` follows the site language (settings.yaml `lang`),
|
||||
* but the admin is deliberately untranslated. Without this a screen reader
|
||||
* read the English interface with a German voice on a `lang: de` site.
|
||||
* The Listbox popup is portalled to <body> and still inherits the root value.
|
||||
*/
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="admin-layout">
|
||||
<div className="admin-layout" lang="en">
|
||||
<AdminShell>{children}</AdminShell>
|
||||
</div>
|
||||
);
|
||||
|
||||
11
app/admin/pages/edit/[slug]/page.tsx
Normal file
11
app/admin/pages/edit/[slug]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PageContentEditor } from '../../../components/page-builder/PageContentEditor';
|
||||
|
||||
/** The block editor for one content page (#722), reached from Pages. */
|
||||
export default async function AdminPageContentPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
return <PageContentEditor slug={slug} />;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default function AdminPagesPage() {
|
||||
<PageHeader
|
||||
kicker="Content"
|
||||
title="Pages"
|
||||
description="The home page hero, standalone albums and every subpage. Drag to reorder; changes go live when saved."
|
||||
description="The home page hero, standalone albums, subpages and content pages. Drag to reorder; changes go live when saved."
|
||||
/>
|
||||
<PageBuilder />
|
||||
</>
|
||||
|
||||
5
app/admin/proofing/page.tsx
Normal file
5
app/admin/proofing/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ProofingView from '../components/ProofingView';
|
||||
|
||||
export default function AdminProofingPage() {
|
||||
return <ProofingView />;
|
||||
}
|
||||
@@ -74,16 +74,32 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.picker-item:hover:not(.used) {
|
||||
.picker-item:hover:not(.used),
|
||||
.picker-item:focus-visible {
|
||||
background: var(--admin-bg-elevated);
|
||||
}
|
||||
|
||||
.picker-item:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.picker-item:focus-visible .picker-select-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.picker-item.used {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -106,25 +106,32 @@
|
||||
align-items: flex-end;
|
||||
background: var(--admin-bg-elevated);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --admin-primary never existed, so every bar fell back to a fixed blue;
|
||||
the accent is what the top-pages bars below use. */
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
background: var(--admin-primary, #3b82f6);
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: var(--admin-accent);
|
||||
border-radius: 6px;
|
||||
transition: height 0.3s ease;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Above the bar, not inside it: inside, a short bar cut its number in half
|
||||
and an empty day had nowhere to put its 0. The chart's padding-top leaves
|
||||
room above the tallest bar. */
|
||||
.bar-val {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-top: 4px;
|
||||
line-height: 1;
|
||||
color: var(--admin-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
|
||||
@@ -20,15 +20,16 @@
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* A warning, so --admin-warning: the accent is red under studio-modern. */
|
||||
.order-editor-notice {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
color: var(--admin-accent);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
color: var(--admin-warning);
|
||||
border: 1px solid color-mix(in srgb, var(--admin-warning) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--admin-warning) 5%, transparent);
|
||||
}
|
||||
|
||||
.order-editor-section + .order-editor-section {
|
||||
@@ -91,15 +92,27 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* A button, so the tail can be pinned from the keyboard. */
|
||||
.order-tile-unpinned {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.order-tile-unpinned:hover {
|
||||
.order-tile-unpinned:hover,
|
||||
.order-tile-unpinned:focus-visible {
|
||||
opacity: 1;
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.order-tile:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.order-tile-index {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
@@ -110,7 +123,7 @@
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
color: var(--admin-on-accent);
|
||||
background: var(--admin-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--admin-accent);
|
||||
color: #000;
|
||||
color: var(--admin-on-accent);
|
||||
font-size: 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -167,3 +167,85 @@
|
||||
margin-top: 0.3rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Search and multi-select (#602) ────────────────────────── */
|
||||
|
||||
.asset-picker-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.asset-picker-search-input {
|
||||
flex: 1;
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: var(--admin-bg-input);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 6px;
|
||||
color: var(--admin-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.asset-picker-search-input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus);
|
||||
}
|
||||
|
||||
/* Tiles are buttons now; reset what the browser gives a button. */
|
||||
button.asset-picker-tile {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.asset-picker-tile:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.asset-picker-tile:disabled:not(.used) {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.asset-picker-tile.selected {
|
||||
border-color: var(--admin-accent);
|
||||
box-shadow: 0 0 0 2px var(--admin-accent);
|
||||
}
|
||||
|
||||
.asset-picker-order-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 11px;
|
||||
background: var(--admin-accent);
|
||||
color: var(--admin-on-accent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.asset-picker-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
font-size: 0.85rem;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.asset-picker-selection > span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ h3 .svg-icon {
|
||||
|
||||
.album-tile-btn:hover {
|
||||
background: var(--admin-accent);
|
||||
color: #000;
|
||||
color: var(--admin-on-accent);
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--admin-accent);
|
||||
color: #fff;
|
||||
color: var(--admin-on-accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
92
app/admin/styles/proofing.css
Normal file
92
app/admin/styles/proofing.css
Normal file
@@ -0,0 +1,92 @@
|
||||
/* The client proofing view (Admin → Proofing). Builds on the analytics panels. */
|
||||
|
||||
.proofing-create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0 1rem;
|
||||
}
|
||||
|
||||
.proofing-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.proofing-card-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.proofing-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.proofing-card-body {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.proofing-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--admin-border);
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.proofing-badge--submitted {
|
||||
border-color: var(--admin-success);
|
||||
color: var(--admin-success);
|
||||
}
|
||||
|
||||
.proofing-badge--expired {
|
||||
border-color: var(--admin-warning);
|
||||
color: var(--admin-warning);
|
||||
}
|
||||
|
||||
.proofing-card--expired {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.proofing-picks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.proofing-pick {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.proofing-pick img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-input);
|
||||
}
|
||||
|
||||
.proofing-pick-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--admin-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -183,7 +183,7 @@
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--admin-accent);
|
||||
color: #fff;
|
||||
color: var(--admin-on-accent);
|
||||
font-family: var(--admin-font-label);
|
||||
font-size: 0.68rem;
|
||||
text-align: center;
|
||||
@@ -214,17 +214,34 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* In the sidebar foot the dropdown opens upwards and to the right. */
|
||||
/* In the sidebar foot the dropdown opens upwards, as wide as the foot. The
|
||||
sidebar scrolls (overflow-y: auto), which clips sideways too: a fixed 280px
|
||||
panel lost its values past the 232px column. */
|
||||
.admin-sidebar .status-dropdown {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: auto;
|
||||
right: 0;
|
||||
margin: 0 0 0.5rem;
|
||||
width: 280px;
|
||||
width: auto;
|
||||
border-radius: var(--admin-radius);
|
||||
}
|
||||
|
||||
.admin-sidebar .status-item {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-sidebar .status-val {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* The collapsed sidebar does not clip (overflow: visible below), so the panel
|
||||
keeps its width and opens out of the 64px column. */
|
||||
.admin-app.is-collapsed .admin-sidebar .status-dropdown {
|
||||
right: auto;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.admin-sidebar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -656,6 +673,7 @@
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.4rem;
|
||||
@@ -836,6 +854,82 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Content pages in the structure list (#722): a document icon and a PAGE tag
|
||||
tell them apart from subpages at a glance. */
|
||||
.pb-row-tag {
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 2px;
|
||||
background: var(--admin-bg-elevated);
|
||||
font-family: var(--admin-font-label);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.pb-offmenu {
|
||||
min-height: 2.25rem;
|
||||
border-radius: var(--admin-radius);
|
||||
}
|
||||
|
||||
.pb-offmenu.is-over {
|
||||
outline: 1px dashed var(--admin-border-hover);
|
||||
}
|
||||
|
||||
.pb-offmenu .empty-hint {
|
||||
margin: 0;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.page-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.page-panel .builder-section-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-panel textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.page-panel-switches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.page-panel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.new-page-card {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.new-page-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pb-add-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pb-add-row .pb-add {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pb-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -868,6 +962,11 @@
|
||||
.subpage-drawer-container--inline {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
/* The overlay sheet is 100dvh tall on a phone (subpage-sheet.css). In place,
|
||||
that height clipped everything past the first screen — album list, grid
|
||||
settings, Delete — and the footer slid over the password field. The
|
||||
sheet grows with its content and the page scrolls. */
|
||||
height: auto;
|
||||
border-radius: var(--admin-radius);
|
||||
box-shadow: none;
|
||||
animation: none;
|
||||
@@ -891,6 +990,11 @@
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* Picking a row scrolls the stacked panel up (PageBuilder revealPanel). */
|
||||
.pb-panel {
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* A custom album title is marked by weight, not by the accent: red italics
|
||||
@@ -1324,3 +1428,34 @@
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Env-locked fields (#605) ──────────────────────────────── */
|
||||
|
||||
/* A field an environment variable overrides: the input is disabled and this
|
||||
note names the variable. Neutral pill, not the warning or accent colour —
|
||||
the lock is a fact about the deployment, not a problem. */
|
||||
.admin-env-lock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.admin-env-lock-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--admin-border-hover);
|
||||
border-radius: 999px;
|
||||
font-family: var(--admin-font-label);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@
|
||||
animation: save-bar-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Unsaved changes are a warning, never the accent: studio-modern makes the
|
||||
accent red, and the bar read as an error. */
|
||||
.floating-save-bar.dirty {
|
||||
border-color: var(--admin-accent);
|
||||
border-color: var(--admin-warning);
|
||||
}
|
||||
|
||||
/* Keyframes must carry the translateX(-50%) or the bar jumps horizontally. */
|
||||
@@ -47,7 +49,7 @@
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ff9800;
|
||||
background: var(--admin-warning);
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
animation: pulse 1.5s infinite;
|
||||
@@ -56,15 +58,15 @@
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-warning) 70%, transparent);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 8px rgba(255, 152, 0, 0);
|
||||
box-shadow: 0 0 0 8px transparent;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +269,11 @@
|
||||
animation: pulse-red 1.5s infinite;
|
||||
}
|
||||
|
||||
/* The doctor found something worth a look, nothing broken: amber, still. */
|
||||
.status-badge-btn.warning .status-dot {
|
||||
background-color: var(--admin-warning);
|
||||
}
|
||||
|
||||
/* The diagnostics request itself did not run — deliberately not red, and not
|
||||
animated: an unanswered check is not a fault report. */
|
||||
.status-badge-btn.unknown .status-dot {
|
||||
@@ -640,6 +645,11 @@
|
||||
color: var(--admin-error);
|
||||
}
|
||||
|
||||
/* A consequence to know about, not an error: --admin-accent may be red (#722). */
|
||||
.admin-field-hint--warn {
|
||||
color: var(--admin-warning);
|
||||
}
|
||||
|
||||
/* A link out of a section header, e.g. to the page the section configures.
|
||||
It shows the saved state, not unsaved edits. */
|
||||
.settings-section-action {
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-accent);
|
||||
color: var(--admin-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
.hover-action-btn {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
color: var(--admin-on-accent);
|
||||
background: var(--admin-accent, #e60012);
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 20px;
|
||||
|
||||
@@ -18,8 +18,13 @@
|
||||
--admin-border-focus: #777777;
|
||||
--admin-text: #f0f0f0;
|
||||
--admin-text-secondary: #a0a0a0;
|
||||
--admin-text-muted: #707070;
|
||||
--admin-accent: #f59e0b;
|
||||
/* ≥4.6:1 on every admin surface (#0c0c0c–#222222); #707070 fell to 3.2:1. */
|
||||
--admin-text-muted: #8a8a8a;
|
||||
/* Not amber: that is --admin-warning, and with both the same every active
|
||||
marker and focus ring read as a warning. 7.7:1 on the background. */
|
||||
--admin-accent: #60a5fa;
|
||||
/* Text on an accent fill (counts, badges). */
|
||||
--admin-on-accent: #000000;
|
||||
/* Focus used to show only as a border colour change from #2e2e2e to
|
||||
#666666, a contrast of 2.37:1 where WCAG 1.4.11 asks for 3:1 against
|
||||
adjacent colours, so keyboard users could barely see where they were.
|
||||
@@ -53,8 +58,10 @@
|
||||
--admin-border-focus: #7f7f7f;
|
||||
--admin-text: #1a1a1a;
|
||||
--admin-text-secondary: #555555;
|
||||
--admin-text-muted: #888888;
|
||||
--admin-accent: #d97706;
|
||||
/* ≥4.6:1 on white and on #efefee; #888888 was 3.1–3.5:1. */
|
||||
--admin-text-muted: #6b6b6b;
|
||||
--admin-accent: #2563eb;
|
||||
--admin-on-accent: #ffffff;
|
||||
--admin-success: #16a34a;
|
||||
--admin-warning: #b45309;
|
||||
--admin-error: #dc2626;
|
||||
@@ -66,6 +73,20 @@
|
||||
--admin-overlay: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* studio-modern restyles the admin in its preset file with a red accent and
|
||||
warm greys. Two of its values are corrected here, one step more specific
|
||||
(html) so the order the sheets load in does not matter: its muted text
|
||||
(#6b6b66 dark, #8a8a84 light) sat at 3.0–3.6:1, and text on its red reads
|
||||
better white (4.8:1) than black (4.4:1). */
|
||||
html[data-preset='studio-modern'] .admin-layout {
|
||||
--admin-text-muted: #8c8c86;
|
||||
--admin-on-accent: #ffffff;
|
||||
}
|
||||
|
||||
html[data-preset='studio-modern'][data-theme='light'] .admin-layout {
|
||||
--admin-text-muted: #6b6b66;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
background: var(--admin-bg);
|
||||
|
||||
Reference in New Issue
Block a user