Commit Graph
828 Commits
Author SHA1 Message Date
Luca 7b36582ef5 fix(crm): make 'Configure defaults in Settings' link navigate in-app
The link was an <a target="_blank"> doing a hard SPA boot in a new tab,
which tripped the error boundary. Switch to a react-router <Link> so it
opens Settings -> CRM the same way the sidebar nav does (known-good path).
2026-06-03 16:01:07 +02:00
Luca 615ef757d3 fix(crm): offer full ISO 3166-1 country list in pickers
The country dropdown was a curated 22-entry European subset; expand it to
the complete ISO 3166-1 alpha-2 set so customers from any country can be
selected. Labels are still derived from Intl.DisplayNames and sorted by
localized name at render time, so no translation map is needed.
2026-06-03 16:01:07 +02:00
Luca 4d3da35168 fix(branding): dark logo in the Branding live preview
GalleryPreview used only branding.logo_url, so the Live Preview kept the
light logo when previewing a dark theme. Pick the logo by theme.colorMode
(symmetric fallback) and pass logo_url_dark through from BrandingPage.
2026-06-03 15:30:16 +02:00
Luca 7d34c97c58 fix(branding): dark-mode logo in the admin sidebar
The sidebar brand row (logo_position=sidepanel) + collapsed rail used
the light logo unconditionally. Make it theme-aware via useAdminDarkMode
with the same symmetric fallback as the header (dark uses dark||light,
light uses light||dark). This was the missing admin surface — the header
already switched.
2026-06-03 15:19:08 +02:00
Luca e8960a41cf i18n(contracts): German translations for the contract detail page
The whole contracts.detail.* namespace was English-fallback-only, so the
contract detail page rendered English in German. Add all 64 keys to en +
de (native German), covering actions, signing/counter-sign, audit trail,
convert, and PDF flows. Also dedupe a duplicate events.createInvoice key
(identical value).
2026-06-03 14:49:22 +02:00
Luca 02742b3163 feat(hours): open the hours invoice in the editor to add items
After 'Create draft invoice' mints the single scheduled invoice from a
per-event customer's unbilled hours, navigate to the bill editor so the
admin can add other line items before it ships (invoice is already
status='scheduled' + editable). Updated the per-event hint copy.
2026-06-03 14:20:49 +02:00
Luca 5d7b545bf7 feat(admin): System health page surfacing stuck/failed emails
New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
2026-06-03 13:56:08 +02:00
Luca a6e6ef7b83 fix(branding): SVG (and .ico) favicons now render
DynamicFavicon hardcoded link.type='image/png', so an SVG/.ico favicon
was declared as PNG and browsers ignored it. Derive the type from the
file extension instead. (Sidebar icon already uses <img> which renders
SVG fine.)
2026-06-03 13:38:52 +02:00
Luca 44590b8c0b feat(branding): symmetric light/dark logo fallback + customer surface
Pick the logo by the active color mode with a symmetric fallback: a
single uploaded logo serves both modes (dark uses dark||light, light
uses light||dark). Apply to admin header, customer gallery, and the
customer portal (follows branding_force_color_mode). PDFs already use
the light branding logo with the business-profile PDF logo as override
(resolveLogoFile) — unchanged.
2026-06-03 13:33:27 +02:00
Luca 4790ea5ccd feat(branding): dark-mode logo variant
Add an optional dark-mode logo (branding_logo_url_dark) alongside the
main logo. Upload/remove via the logo endpoint (?variant=dark) on the
Branding settings page. Admin header (admin dark mode) and the public
gallery (dark themes) pick the dark logo when active, falling back to
the light logo when unset. PDFs keep using the light logo.
2026-06-03 13:26:56 +02:00
Luca d7f0488f6e feat(crm): cross-link to CRM settings from quote + invoice editors
Add a 'Configure defaults in Settings' link (opens Settings → CRM in a
new tab) under the payment-conditions section of the quote and invoice
editors, so the admin can jump to the payment-term / Skonto / numbering
defaults without hunting for the settings page.
2026-06-03 13:12:28 +02:00
Luca 7eec1337da feat(contracts): preview PDF before sending
Add a 'Preview PDF' button on draft contracts that renders a fresh PDF
via the existing no-write /preview endpoint, so the admin can check
layout + signature blocks before sending (no audit trail created).
2026-06-03 13:10:06 +02:00
Luca f3a6c8940a fix(date-input): render pg full-ISO dates in the configured format
LocalizedDateInput.toDisplay only matched a bare yyyy-MM-dd, but Postgres
serializes DATE columns as a full ISO datetime, so the field printed the
raw "2026-…T…Z" string (SQLite returned a bare date, hiding it). Match the
leading yyyy-MM-dd of any ISO value and slice the hidden native picker's
value to 10 chars. Fixes ISO-form dates on customer/event/passive-create.
2026-06-03 11:23:58 +02:00
Luca a03146edc9 feat(events): "Create invoice" action on the event detail page
Add a bills-gated button that opens the bill editor pre-filled with the
event (eventId FK + name/date snapshot) and the linked customer (when
exactly one). BillEditorPage gains eventId state + query-param prefill +
sends eventId on create; backend validates eventId (already forwarded +
persisted). Reuses the editor — no empty drafts. Does not auto-pull hours.
2026-06-03 00:07:00 +02:00
Luca 626ab45e0b refactor(time): app-wide setting-aware TimeField for all time inputs
Add shared components/common/TimeField (displays per general_time_format,
stores canonical HH:MM, parses tolerant free-text, browser-independent)
and migrate every native <input type="time"> to it: business hours,
HoursSection, HourEntryInlinePopover, CreateEventPage, Quote/Bill/Contract
editors. Removes the unreliable lang-hint plumbing.
2026-06-02 17:24:27 +02:00
Luca 2149f38cd1 fix(business-profile): setting-aware custom time field for business hours
Native <input type="time"> ignores general_time_format (browser-locale
controlled; lang hint failed in Safari and Ralf's Chrome for both en-GB
and de-DE). Replace with a custom TimeField text input that displays per
general_time_format (24h "13:00" / 12h "01:00 PM") and stores canonical
HH:MM, parsing tolerant free-text on blur. Keeps the fixed-width
alignment fix.
2026-06-02 16:57:08 +02:00
Luca e1d9d06ae9 fix(business-profile): use the app-standard de-DE lang hint for 24h time
Business-hours time pickers used lang="en-GB", which didn't render 24h in
Chrome. Switch to lang={timeFormat==='12h'?'en-US':'de-DE'} — the same hint
HoursSection/Quote/Bill/Contract editors use — so the picker shows 24h in
Chrome/Edge. Keep the fixed-width plain <input> for column alignment.
2026-06-02 16:36:46 +02:00
Luca f4af290fa0 fix(business-profile): align business-hours time columns
The shared Input wraps fields in a w-full div, so two per flex row split
the width and the trailing +/trash buttons knocked columns out of
alignment. Use a plain fixed-width <input> for the start/end time fields.
2026-06-02 15:16:52 +02:00
Luca 2ef837680e fix(i18n): honor general_time_format across all time displays
Route 10 surfaces that hardcoded 12-hour date-fns patterns
('h:mm a', 'PPp', 'p', toLocaleTimeString) through useLocalizedDate's
formatDateTime/formatTime so they respect general_date_format +
general_time_format: backup/restore, archives, photo viewer, feedback,
event details, gallery timeline, public quote page, CMS save indicator.
Also pin a lang hint on the business-hours native time inputs (Chrome/Edge
render 24h). Drops now-unused date-fns imports.
2026-06-02 15:13:55 +02:00
Luca 2723b3f29e fix(business-profile): honor 24h time format in business-hours pickers
Native <input type="time"> renders AM/PM from the browser locale, ignoring
general_time_format. Pin a lang hint (en-GB for 24h, en-US for 12h) so
Chrome/Edge render the admin's chosen format. Display-only; the stored
value was already 24h HH:MM.
2026-06-02 15:03:35 +02:00
Luca 333379b321 Merge branch 'beta' of https://github.com/the-luap/picpeak into feat/crm-improvements 2026-06-02 14:17:46 +02:00
Luca a5c88f2d9f i18n(crm): fill missing DE/EN keys on CRM admin surfaces
The t() calls on the Features tab, CRM settings tab, contract editor,
and settings nav carried English fallbacks but the keys were absent from
both locale files, so DE rendered English. Add ~67 keys to en.json +
de.json (DE native): the full contracts.editor.* subtree (whole page was
English), settings.features.{contracts,crmDevelopment,hoursLogging}.*,
crmSettings contracts/dashboard-overview/ToS labels, and two settings
nav titles. Also drop a dead duplicate bills.field.sourceQuote key in de.
No code changes — additive translations only.
2026-06-02 14:11:44 +02:00
Luca 603ba1e504 feat(email): read-only "Sent emails" tab over email_queue
Add a paginated, filterable view of the email_queue (recipient, type,
status, queued/sent timestamps, error, event link) as a third tab in
Email config, beside SMTP + Templates. Filters: status, recipient/type
search, created-at range. email_data is never exposed. Pairs with the
"Send queued emails now" flush — flush, then watch what sent/failed.
2026-06-02 13:58:54 +02:00
Luca 8d14441df4 feat(quotes): admin decline-on-behalf with optional reason
Add a "Decline on behalf" action mirroring accept-on-behalf, for when a
customer says no by phone/email. Flips a draft/sent/expired quote to
declined, stamps declined_at, closes the public response window, and
invalidates outstanding accept/decline tokens so the emailed link can't
toggle it back. Optional free-text reason persisted to a new
quotes.decline_reason column (migration 115) and shown on the quote
detail page. Hard-delete intentionally not included.
2026-06-02 13:47:31 +02:00
Luca 621ce942b5 feat(email): per-weekday business hours + manual queue flush
Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
2026-06-02 13:00:13 +02:00
Luca 93956db0ca feat(hours): aggregate open-hours landing view on /admin/clients/hours
When no customer is selected, list every customer with unbilled hour
entries — entry count, total hours, and open amount (resolved via the
override → customer-rate → install-default chain). Rows with no
resolvable rate are flagged "Rate not set" rather than undercounted.
Click a row to drill into the per-customer logging section.

Backend: getUnbilledSummaryByCustomer() + GET
/api/admin/customers/hour-entries/unbilled-summary (customers.view).
2026-06-02 11:43:42 +02:00
Luca ab6bad17c9 feat(hours): install-wide default rate + inline missing-rate CTA
Hour-entry saves hard-failed with an English-only error when a customer
had no rate, and the standalone hours page showed a disabled rate field
that looked set. Add a global business_profile default_hourly_rate_minor
(migration 113) as the last link in the rate chain
(entry override → customer → install default), so saves succeed with the
global rate. When no rate resolves anywhere, replace the save-time error
with a read-only resolved-rate display + a CTA to set a customer or
install-wide rate, disable Add-entry until a rate/override exists, and
translate the backend HOURLY_RATE_REQUIRED toast (en+de).
2026-06-02 11:33:45 +02:00
Luca d9251c0850 feat(crm): Finder-style sortable column headers, default sort by issue date
Replace the sort <select> dropdowns on the invoice, quote and contract
list pages with clickable column headers that toggle asc/desc and show a
chevron indicator. Adds a shared SortableHeader component + useColumnSort
hook that maps clickable columns onto the server-side sort enum.
Make issue date (newest first) the standard sort on all three lists,
set at the frontend, route and service layers. Adds issue_asc/issue_desc
to invoices and an "Issued" column to the bills table so the default is
visible and toggleable. Extends sort coverage so every clickable column
has both directions (+customer_desc on all; +issue_asc/desc on
quotes/contracts). Storno rows remain listed.
2026-06-02 10:59:36 +02:00
Luca 095edfe06d fix(bills): localize the event_date on the invoice detail card
The Anlass field rendered inv.eventDate verbatim (raw ISO from pg
date-as-Date serialization) while every other date on the card went
through useLocalizedDate. Route it through fmtDate so it honors the
general_date_format setting. (The event_id → /admin/events linkify was
already in place.)
2026-06-02 10:04:23 +02:00
Luca db7d11dc7f feat(bills): capture event name/date when importing historical invoices
The historical-invoice import form had no event field, so imported rows
landed with event_name = NULL even when the admin knew the occasion. Add
free-text Event name + Event date inputs to the import modal, thread them
through billsService.importHistorical and the POST /admin/invoices/import
validator, and store them in the event_name/event_date snapshot columns
(migration 107). event_id stays NULL — no FK, since the event may predate
picpeak. Autocomplete-to-event_id linking deferred as a future bonus.
2026-06-02 09:50:51 +02:00
Luca c2d3f663bc fix(settings): use CountrySelect for business-profile country (no more FL)
The business-profile country field — the seed source for the customer-create
country default — was still a free-text input placeholdered "FL", which could
reintroduce the non-ISO "FL" code that migration 110 normalized to "LI" and
re-open the create/edit CH-vs-FL default mismatch. Swap it for the shared
CountrySelect so every surface stores ISO alpha-2. The free-text countryName
verbatim-PDF override (migration 107) is unchanged.
2026-06-02 09:37:33 +02:00
Luca 06b4522dec feat(billing): manual cadence + fix admin date inputs ignoring date-format setting
Manual billing cadence
  Adds a "Manual (trigger only)" cadence alongside monthly/quarterly. It reuses
  the monthly draft accumulator — invoices and billed hours pile onto one running
  draft — but stores NULL monthly_period_start/end so the scheduler's auto-flush
  never matches. The draft ships only when an admin clicks "Trigger invoice now".
  No migration: billing_cadence is a free-form string column gated by validators.
  - adminCustomers.js: allow 'manual' in billing_cadence validator
  - invoiceService.js: route manual through accumulator; NULL periods + placeholder
    issue/due date in getOrCreateMonthlyDraft
  - customerHoursService.js: manual auto-appends hours to running draft;
    billUnbilledEntries refuses manual (CADENCE_MISMATCH)
  - CustomerDetailPage.tsx: dropdown option, cycle-day hidden for manual,
    NULL-period-safe draft preview + trigger button, manual-specific copy
  - customerAdmin.service.ts: cadence union + nullable periodStart/periodEnd
  - en.json / de.json: manual, triggerConfirmManual, triggerHintManual,
    draftPreview.titleManual
Date-format fixes
  Replace raw <input type="date"> (browser-locale) with LocalizedDateInput so
  these admin surfaces honor the general_date_format setting:
  - ContractEditorPage.tsx (issue / valid-until / event dates)
  - QuoteEditorPage.tsx (event / valid-until dates)
  - EventDetailsPage.tsx (expiry date)
  - HoursSection.tsx (entry date)
2026-06-02 09:27:10 +02:00
github-actions[bot] 3361a8bb51 chore(beta): release 3.60.1-beta.0 2026-06-02 07:17:10 +00:00
github-actions[bot] 987911a03b chore(beta): release 3.60.0-beta.0 2026-06-02 07:11:30 +00:00
Paul Nothaft 9d424d0dbb Merge pull request #596 from Luca-Timo/bugfix/crm-backup
Backup & Restore hardening — close the silent files-only data-loss class
2026-06-02 09:10:57 +02:00
Luca d788a6cde5 feat(crm): per-customer Skonto opt-out
Adds customer_accounts.skonto_disabled (migration 112) so a customer
that negotiated "no early-payment discount" can be flagged once instead
of ticking the per-invoice toggle on every invoice. resolveSkontoPercent
ForInvoice and the PDF render context both honour it, extending the
resolution chain to customer → invoice → snapshot → quote → global.
Checkbox added to the customer detail Billing card (en + de).
2026-06-02 09:00:05 +02:00
Luca 7c39a30957 feat(bills): link event label to its event detail page
The Anlass / event name on the invoice detail page and the bills list
now links through to /admin/events/:id when the invoice references a
real event row. The list link stops propagation so it doesn't trigger
the row's invoice navigation. Falls back to plain text when the invoice
carries only a free-text event snapshot. Customer portal unchanged
(no admin route access).
2026-06-02 08:38:07 +02:00
Luca 522cf2aa4a fix(invoices): auto-track due date from send date + payment term
Due date now derives from (scheduled send date else issue date) plus the
selected Net-days template, both in the editor and on save. The bill
editor renders it read-only with an Override toggle for manual entry;
existing invoices preserve their stored due date. Backend adds a single
resolveNetDays resolver that honors the split payment-net-days template
(previously only the legacy FK was read) and the
crm_payment_default_net_days setting, used by createInvoice and the
installment-spawn path alike.
2026-06-02 01:52:45 +02:00
Luca db2c482ae9 feat(crm): country dropdown + name guard for customer create/edit
Replace the free-text 2-char country code field on the inline customer
create form and the customer detail page with a dropdown that shows
localized country names (Intl.DisplayNames, no hardcoded map) while
still storing the ISO 3166-1 alpha-2 code. The create form now seeds the
default country from the business profile instead of leaving it blank or
guessing CH/FL. The free-text countryName override is kept for the rare
case where an operator wants a custom display string.

Standardize Liechtenstein on the ISO code LI instead of the colloquial
plate code FL so it matches the PDF renderer's locale-aware lookup and
the new dropdown. Migration 110 normalizes existing FL rows to LI on
customer_accounts and business_profile (idempotent, case-insensitive).

Require at least one human-readable identifier (company name or a
contact name) at create time so the form can't produce a nameless row
that's impossible to recognise in lists later. Enforced on both the
frontend (isValid + toast) and the backend POST /admin/customers
validator so the API can't be bypassed.

i18n: en + de updated; other locales fall back to inline English
defaults and should get a native review before release.
2026-06-02 01:28:00 +02:00
Luca 840df52581 fix(crm): respect general_date_format on all admin date inputs
Admin date inputs were inconsistent: raw <input type="date"> on event
creation and the bill editor rendered in the browser locale (en-US users
saw MM/DD/YYYY regardless of Settings -> General), while the historical-
invoice import modal used a private LocalizedDateField that displayed the
configured format but showed a text box plus a tiny native date stub
side-by-side ("two date fields, looks corrupted").

Extract a single shared LocalizedDateInput that displays/parses in the
configured general_date_format on every browser and opens the native
picker via a calendar icon button (showPicker on a visually-hidden native
input), so there is one date field, not two. Wire it into event creation,
the bill editor (event/issue/due dates), the import modal, and the tax-
report range filters (dropping the Chromium-only lang={dateInputLang}
workaround there).
2026-06-02 01:15:40 +02:00
Luca 09f6a1af6a fix(backup-ui): respect general_date_format + general_time_format
The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.

Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.

All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).

Tokens replaced:
  format(date, 'p')              → formatTime(date)
  format(date, 'PP')             → format(date)
  format(date, 'PPP')            → format(date)
  format(date, 'PPp')            → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm')    → formatDateTime(date)

No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
2026-05-31 23:33:50 +02:00
github-actions[bot] b646dab493 chore(beta): release 3.59.1-beta.0 2026-05-31 21:10:15 +00:00
Paul Nothaft c246fd3cc8 fix(admin-header): hide wordmark on <sm when logo also shows (#523)
Rekoo-PS's v3.59.0-beta.0 screenshot showed a different shape than
the truncate fix in e7cf834 addressed. Their company name ("Arkan
Studio") isn't unusually long, but with logo_and_text display mode
on a phone-width viewport the wordmark wrapped to two lines and the
LanguageSelector button — sitting in the right action cluster —
landed visually on top of the wrapped second line.

Truncate alone left "Arkan Studio" rendered as "Ar..." after the
logo image. Functional but ugly, and on accounts where the wordmark
reaches the right cluster the visual overlap returns. Match what
LanguageSelector does for its language name in #527: hide the
wordmark on <sm when a logo is also showing (the logo carries the
identity), keep it on sm+. text_only mode is unchanged — wordmark
shows on every width, otherwise nothing would render.

Truncate stays in place as defensive depth for the text_only path.
2026-05-31 23:07:19 +02:00
Luca e7dffa656b feat(backup-stats): per-Stage-B-path counters in backup statistics
Closes the last gap from tonight's backup-hardening: backup_runs.
statistics now carries a `per_path` map keyed by backup_paths.path
(e.g. `events/active`, `business-docs`), with per-bucket count + size.

Backend (backupService.js):
  - new `computePerPathStats(backedUpFiles, allFiles)` helper that
    bucket-sorts each backed-up file into its owning backup_paths row
    by longest-prefix match. Reuses the same backup_paths source the
    walker reads, so toggling include_in_default off propagates
    correctly. Falls back to LEGACY_BACKUP_PATHS if the table is
    missing.
  - runBackupInternal calls it after the destination implementation
    reports back, includes the result in statistics under both
    snake_case (`per_path`) and camelCase (`perPath`) keys for the
    same alias treatment the existing fields get.

Frontend (BackupHistory.jsx):
  - Backup History detail pane now renders one row per per_path entry
    when present, with path label + count + formatted size.
  - Falls back to the legacy Photos / Archives / "Other" rendering
    when the field is absent (backups taken before this commit). No
    breaking change for stored history.

Tests: new backupService.perPathStats.test.js — 2 scenarios pinning
attribution behaviour (single-path, nested-paths-don't-collide).
Plus a NOTE comment about overlapping-path walker behaviour (out of
scope; canonical seed doesn't hit it).
2026-05-31 22:54:09 +02:00
Paul Nothaft 791e9974eb fix(gallery): preserve per-viewer is_liked across hard refresh (#590 follow-up)
The in-session toggle fix in d292b9f handles click 2 correctly, but
on a hard refresh likedPhotoIds was always initialized to an empty
Set — so previously-liked photos rendered un-filled until the user
opened the lightbox.

Backend: gallery.js GET /:slug/photos now mounts resolveGuest and
emits a per-viewer is_liked boolean per photo. Prefers req.guest.id
when a verified guest token is present (per-person identity), falls
back to the IP+UA hash that generateGuestIdentifier produces — same
identity model galleryFeedback.js uses for /my-feedback. Skipped
when feedback is hidden from guests.

Frontend: Photo type gains optional is_liked. Each of the 7 grid
layouts (Masonry / Grid / Justified / Timeline / Carousel / Mosaic /
Premium) seeds its lifted likedPhotoIds Set from photos.filter(is_liked)
on the first non-empty payload, gated by a seededRef so subsequent
React Query refetches don't clobber in-session optimistic toggles.
Mosaic uses photo.is_liked ?? false in its per-card useState initializer.

GalleryPremium also drops the buggy `|| like_count > 0` fallback at
line 521 that treated "anyone liked this" as "I liked it" — the
per-viewer seed is now the correct source.

GalleryStory had the same shape of bug in two places — same #590 fix:
- Seed switched from like_count > 0 (global) to is_liked (per-viewer),
  with the same mount-only seededRef guard.
- handleToggleFavorite now calls submitFeedback on EVERY click, not
  only when adding. The previous code skipped the unlike submit, so
  the UI removed the heart while the server kept the like row.
2026-05-31 22:47:16 +02:00
Luca 155aa63103 fix(backup-dashboard): show last SUCCESSFUL backup + last attempt separately
The dashboard widget used `lastBackup.created_at` for the "Last
successful backup: X ago" text — but lastBackup is the most recent
row of any status. So a crashed restore (status=running, never
updated) or a recent failure showed up labeled as the last
successful backup. Same "silent failure not surfaced" class the
restore wizard had.

Backend now returns:
  lastSuccessfulBackup — most recent backup_runs with status='completed'
  zombieRuns — running rows older than 30 min (likely crashed mid-flight)
  lastBackup — unchanged (most recent any status)

Frontend renders:
  - "Last successful backup: X ago"  — always from lastSuccessfulBackup
  - "Last attempt: Y ago · failed/running"  — when lastBackup differs
    from lastSuccessful. failed shows the first line of error_message
    in red; running stays neutral.
  - Zombie callout — "N backup(s) running >30min — may have crashed"
    in amber, so admin sees stuck rows at a glance.
  - Health score downgrades from "excellent" to "warning" if the
    latest attempt failed, even when older successes keep the age
    fresh — surfaces regressions without erasing the green history.
2026-05-31 22:44:22 +02:00
Luca 47ed6907d1 fix(restore-wizard): surface failure status, stop showing "completed at 0%"
The progress step used a binary `isRunning ? "in progress" : "completed"`
check. So when the backend rejected the restore (pre-flight validator
threw, path error, etc.) the wizard cheerfully rendered "Restore
completed" with 0% progress and no error context — the admin had to
SSH into the server and inspect `restore_runs.error_message` to find
out what happened.

Now reads the most recent row from `restoreStatus.history[0]` and
renders one of three states:
  - running    → blue text, progress bar updates
  - succeeded  → green tick + post-restore actions (existing behaviour)
  - failed     → red banner with the first line of error_message, and
                 a callout if was_rollback_attempted is true so the
                 admin knows the destination is safe to retry on top of.

Net: the wizard now tells the truth about what just happened.
2026-05-31 22:44:00 +02:00
Paul Nothaft c83e88348f fix(nginx): defensive large_client_header_buffers bump (#591)
Default nginx is 4 8k — too tight when an outer Cloudflare /
corp-proxy injects long Set-Cookie / X-Forwarded-* headers, or when
a power-user accumulates many per-gallery gallery_token_<slug>
cookies over the 24h maxAge in tokenUtils.js. Either way users hit
"400 Request Header Or Cookie Too Large" and clearing cookies is
the only workaround.

4×32k is cheap RAM, matches what most reverse proxies do upstream,
and means PicPeak doesn't fail the request before the upstream even
sees it.
2026-05-31 22:35:19 +02:00
Paul Nothaft d292b9fa10 fix(gallery): toggle (not add) the local liked set on click (#590)
The /feedback like endpoint is a server-side toggle — the same one
the lightbox uses. Every grid layout's optimistic-UI setter only
ever did next.add(photoId), so click 2 on a liked tile fired a
server unlike but kept the heart filled in the UI.

Switch each setter to toggle (delete if present, else add). Covers
Masonry (default), Grid, Justified, Timeline, Carousel, Mosaic, and
Premium layouts — including their identity-modal callback paths for
shape consistency. Lightbox toggle is unchanged (already correct).
2026-05-31 22:35:15 +02:00
Paul Nothaft e7cf834325 fix(admin-header): truncate long company names on narrow widths (#523 regression)
#527 hid the language *name* on <sm to free space for the title.
Since then the right cluster gained dark-mode toggle, notifications,
and the user avatar, and the brand block still had no truncation —
so a long branding_company_name would still push past the available
width into the action buttons on phones.

Defensive fix: min-w-0 on the brand-block wrapper, truncate on the
company-name span, flex-shrink-0 on the logo image. Long names now
ellipsis within the left cluster regardless of how many widgets
fill the right.
2026-05-31 22:35:07 +02:00