release: v0.18.1 (#734)

## Thanks

- [@RichKidsDev](https://github.com/RichKidsDev) for reporting the admin
layout on iPhone (#732).

### Fixed

- **The admin fits a phone**
  ([#733](https://github.com/ralksta/immich-folio/pull/733), reported in
  [#732](https://github.com/ralksta/immich-folio/issues/732) by
[@RichKidsDev](https://github.com/RichKidsDev)). Below 860px the sidebar
  unfolded above the content — navigation, status and four actions — and
filled most of an iPhone screen. It is now a header row with the brand
and
a menu button; the menu opens as one column of full-width rows and
closes
again on navigation, and an unread message shows as a dot on the button.
  Desktop is unchanged.

- **The settings editor refuses prototype keys in a setting's path**
([#731](https://github.com/ralksta/immich-folio/pull/731)). All paths
are
written in the code, so this was not reachable; CodeQL flagged the
setter
  after the v0.18.0 release.

---------

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:
Ralksta
2026-09-27 16:39:34 +02:00
committed by GitHub
parent 3193c500e4
commit 5f251923f0
7 changed files with 301 additions and 164 deletions

View File

@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [0.18.1] — 2026-09-27
### Fixed
- **The admin fits a phone**
([#733](https://github.com/ralksta/immich-folio/pull/733), reported in
[#732](https://github.com/ralksta/immich-folio/issues/732) by
[@RichKidsDev](https://github.com/RichKidsDev)). Below 860px the sidebar
unfolded above the content — navigation, status and four actions — and
filled most of an iPhone screen. It is now a header row with the brand and
a menu button; the menu opens as one column of full-width rows and closes
again on navigation, and an unread message shows as a dot on the button.
Desktop is unchanged.
- **The settings editor refuses prototype keys in a setting's path**
([#731](https://github.com/ralksta/immich-folio/pull/731)). All paths are
written in the code, so this was not reachable; CodeQL flagged the setter
after the v0.18.0 release.
## [0.18.0] — 2026-09-27
### Security

View File

@@ -13,7 +13,7 @@
A self-hosted portfolio powered by [Immich](https://immich.app). It acts as a **secure reverse proxy** between your visitors and your private Immich instance: your Immich server stays on your local network, completely invisible to the outside world, while your albums are published as a gallery you control.
**Latest: v0.18.0** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new)
**Latest: v0.18.1** — built-in contact form, privacy page and a reworked admin panel. → [What's New](#whats-new)
## Contents

View File

@@ -112,6 +112,11 @@ const SIDEBAR_KEY = 'folio_admin_sidebar';
export default function AdminDashboard({ onLogout, children }: Props) {
const notify = useNotify();
const pathname = usePathname();
// Phones: the sidebar folds into a header row, and the nav opens from a
// menu button (#732) — spread out above the content, it took most of an
// iPhone screen before anything could be edited.
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => setMenuOpen(false), [pathname]);
const [saving, setSaving] = useState(false);
// Diagnostics & Backup state
@@ -267,7 +272,11 @@ export default function AdminDashboard({ onLogout, children }: Props) {
const missingCredentials = status?.setup?.credentials === 'missing';
return (
<div className={`admin-dashboard admin-app${collapsed ? ' is-collapsed' : ''}`}>
<div
className={`admin-dashboard admin-app${collapsed ? ' is-collapsed' : ''}${
menuOpen ? ' menu-open' : ''
}`}
>
<aside className="admin-sidebar" aria-label="Admin">
<button
type="button"
@@ -298,175 +307,201 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<span className="admin-brand-sub">Admin</span>
</span>
</Link>
<nav className="admin-nav">
{NAV.map((section, i) => (
<div key={section.group ?? i} className="admin-nav-group">
{section.group && <span className="admin-nav-label">{section.group}</span>}
{section.items.map((item) => {
const active = item.match.test(pathname);
const count = item.badge === 'messages' ? unread : 0;
return (
<Link
key={item.href}
href={item.href}
className={`admin-nav-item ${active ? 'active' : ''}`}
data-tip={item.label}
aria-current={active ? 'page' : undefined}
>
<span className="admin-nav-icon">{item.icon}</span>
<span className="admin-nav-text">{item.label}</span>
{count > 0 && (
<span className="admin-nav-count" aria-label={`${count} unread`}>
{count}
</span>
)}
{item.badge === 'health' && health.tone === 'disconnected' && (
<span className={`admin-nav-dot ${health.tone}`} aria-label={health.label} />
)}
</Link>
);
})}
</div>
))}
</nav>
<div className="admin-sidebar-foot">
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn sidebar-status ${health.tone}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
}}
title="Show system status"
data-tip={health.label}
>
<span className="status-dot"></span>
<span className="status-text">{health.label}</span>
</button>
{showStatus && (
<>
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
<div className="status-dropdown">
<div className="status-dropdown-header">
<h4>System Diagnostics</h4>
<button
className="status-refresh-btn"
onClick={fetchStatus}
disabled={statusLoading}
title="Refresh diagnostics"
<button
type="button"
className="admin-menu-toggle"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-controls="admin-sidebar-menu"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
{menuOpen ? <path d="M6 6l12 12M18 6L6 18" /> : <path d="M4 7h16M4 12h16M4 17h16" />}
</svg>
{unread > 0 && !menuOpen && <span className="admin-menu-dot" aria-hidden="true" />}
</button>
<div className="admin-sidebar-menu" id="admin-sidebar-menu">
<nav className="admin-nav">
{NAV.map((section, i) => (
<div key={section.group ?? i} className="admin-nav-group">
{section.group && <span className="admin-nav-label">{section.group}</span>}
{section.items.map((item) => {
const active = item.match.test(pathname);
const count = item.badge === 'messages' ? unread : 0;
return (
<Link
key={item.href}
href={item.href}
className={`admin-nav-item ${active ? 'active' : ''}`}
data-tip={item.label}
aria-current={active ? 'page' : undefined}
>
{statusLoading ? '...' : '↻'}
</button>
</div>
<div className="status-dropdown-body">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
{immichIndicator.label}
</span>
<span className="admin-nav-icon">{item.icon}</span>
<span className="admin-nav-text">{item.label}</span>
{count > 0 && (
<span className="admin-nav-count" aria-label={`${count} unread`}>
{count}
</span>
)}
{item.badge === 'health' && health.tone === 'disconnected' && (
<span
className={`admin-nav-dot ${health.tone}`}
aria-label={health.label}
/>
)}
</Link>
);
})}
</div>
))}
</nav>
<div className="admin-sidebar-foot">
{/* Diagnostics Badge */}
<div className="status-indicator-container">
<button
className={`status-badge-btn sidebar-status ${health.tone}`}
onClick={() => {
setShowStatus(!showStatus);
if (!showStatus) fetchStatus();
}}
title="Show system status"
data-tip={health.label}
>
<span className="status-dot"></span>
<span className="status-text">{health.label}</span>
</button>
{showStatus && (
<>
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
<div className="status-dropdown">
<div className="status-dropdown-header">
<h4>System Diagnostics</h4>
<button
className="status-refresh-btn"
onClick={fetchStatus}
disabled={statusLoading}
title="Refresh diagnostics"
>
{statusLoading ? '...' : '↻'}
</button>
</div>
<div className="status-item">
<span className="status-label">Config Integrity</span>
<span className={`status-val ${configIndicator.className}`}>
{configIndicator.label}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Backups</span>
<span className="status-val">{status?.backups?.count ?? 0} Backups</span>
</div>
<div className="status-item">
<span className="status-label">Latest Backup</span>
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
{status?.backups?.lastBackup
? new Date(status.backups.lastBackup).toLocaleDateString()
: 'None'}
</span>
</div>
<div className="status-item">
<span className="status-label">In-Memory Cache</span>
<span className="status-val">{status?.cache?.size ?? 0} items</span>
</div>
{/*
<div className="status-dropdown-body">
{setupIncomplete && (
<p className="status-setup-note">
{missingCredentials
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
<a href="/install" target="_blank" rel="noopener noreferrer">
setup wizard
</a>
{missingCredentials
? '.'
: ' (the one-time token is printed to the server log).'}
</p>
)}
<div className="status-item">
<span className="status-label">Immich Connection</span>
<span className={`status-val ${immichIndicator.className}`}>
{immichIndicator.label}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Integrity</span>
<span className={`status-val ${configIndicator.className}`}>
{configIndicator.label}
</span>
</div>
<div className="status-item">
<span className="status-label">Config Backups</span>
<span className="status-val">{status?.backups?.count ?? 0} Backups</span>
</div>
<div className="status-item">
<span className="status-label">Latest Backup</span>
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
{status?.backups?.lastBackup
? new Date(status.backups.lastBackup).toLocaleDateString()
: 'None'}
</span>
</div>
<div className="status-item">
<span className="status-label">In-Memory Cache</span>
<span className="status-val">{status?.cache?.size ?? 0} items</span>
</div>
{/*
Discreet on purpose: a row in the status list, not a
banner. When the check is off or could not run, the row
still names the running version and says nothing about
updates (#496).
*/}
<div className="status-item">
<span className="status-label">Version</span>
{status?.update?.updateAvailable ? (
<a
className="status-val update"
href="https://github.com/ralksta/immich-folio/releases/latest"
target="_blank"
rel="noopener noreferrer"
title={`Version ${status.update.latest} is available`}
>
{status.update.current} → {status.update.latest}
</a>
) : (
<span className="status-val">{status?.update?.current ?? '—'}</span>
)}
<div className="status-item">
<span className="status-label">Version</span>
{status?.update?.updateAvailable ? (
<a
className="status-val update"
href="https://github.com/ralksta/immich-folio/releases/latest"
target="_blank"
rel="noopener noreferrer"
title={`Version ${status.update.latest} is available`}
>
{status.update.current} → {status.update.latest}
</a>
) : (
<span className="status-val">{status?.update?.current ?? '—'}</span>
)}
</div>
</div>
</div>
</div>
</>
)}
</div>
</>
)}
</div>
<div className="admin-sidebar-actions">
<a
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
className="admin-side-action"
data-tip="View site"
title="Open site in new tab (bypassing cache)"
>
<Icons.IconLink size={14} />
<span className="admin-side-label">View site</span>
</a>
<button
className="admin-side-action"
data-tip="Backups"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} />
<span className="admin-side-label">Backups</span>
</button>
<button
className="admin-side-action"
data-tip="Reload"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
<Icons.IconRefresh size={14} />
<span className="admin-side-label">Reload</span>
</button>
<button className="admin-side-action" data-tip="Sign out" onClick={handleLogout}>
<Icons.IconX size={14} />
<span className="admin-side-label">Sign out</span>
</button>
<div className="admin-sidebar-actions">
<a
href="/?fresh=1"
target="_blank"
rel="noopener noreferrer"
className="admin-side-action"
data-tip="View site"
title="Open site in new tab (bypassing cache)"
>
<Icons.IconLink size={14} />
<span className="admin-side-label">View site</span>
</a>
<button
className="admin-side-action"
data-tip="Backups"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} />
<span className="admin-side-label">Backups</span>
</button>
<button
className="admin-side-action"
data-tip="Reload"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
<Icons.IconRefresh size={14} />
<span className="admin-side-label">Reload</span>
</button>
<button className="admin-side-action" data-tip="Sign out" onClick={handleLogout}>
<Icons.IconX size={14} />
<span className="admin-side-label">Sign out</span>
</button>
</div>
</div>
</div>
</aside>

View File

@@ -188,6 +188,12 @@ export default function SettingsEditor() {
setSettings((s) => {
const copy = JSON.parse(JSON.stringify(s));
const parts = path.split('.');
// Paths are literals in the section components, but a key like
// `__proto__` would write through to Object.prototype (CodeQL
// js/prototype-pollution-utility).
if (parts.some((p) => p === '__proto__' || p === 'constructor' || p === 'prototype')) {
return s;
}
let obj = copy;
for (let i = 0; i < parts.length - 1; i++) {
if (!obj[parts[i]]) obj[parts[i]] = {};

View File

@@ -1247,3 +1247,80 @@
display: none;
}
}
/* ── Phone header (#732) ───────────────────────────────────── */
/* On a phone the sidebar used to unfold above the content — nav, status,
and four actions — and filled most of an iPhone screen. It is now a
header row with the brand and a menu button; the rest opens on demand. */
.admin-menu-toggle {
display: none;
}
@media (max-width: 860px) {
.admin-sidebar {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0;
padding: 0.6rem 1rem;
}
.admin-sidebar .admin-brand {
margin: 0;
}
.admin-menu-toggle {
position: relative;
display: inline-grid;
place-items: center;
width: 40px;
height: 40px;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius);
background: transparent;
color: var(--admin-text);
cursor: pointer;
}
.admin-menu-toggle:focus-visible {
outline: var(--admin-focus-ring);
outline-offset: 2px;
}
.admin-menu-dot {
position: absolute;
top: 7px;
right: 7px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--admin-accent);
}
.admin-sidebar-menu {
display: none;
grid-column: 1 / -1;
padding-top: 0.75rem;
}
.admin-app.menu-open .admin-sidebar-menu {
display: block;
}
.admin-app.menu-open .admin-sidebar {
max-height: 100dvh;
overflow-y: auto;
}
/* One column: tap targets of a full row, not a wrapped cloud of links. */
.admin-nav {
flex-direction: column;
flex-wrap: nowrap;
gap: 0;
}
.admin-nav .admin-nav-item {
min-height: 44px;
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-folio",
"version": "0.18.0",
"version": "0.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-folio",
"version": "0.18.0",
"version": "0.18.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-folio",
"version": "0.18.0",
"version": "0.18.1",
"license": "MIT",
"scripts": {
"dev": "next dev",