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 Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases). [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 ## [0.18.0] — 2026-09-27
### Security ### 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. 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 ## Contents

View File

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

View File

@@ -188,6 +188,12 @@ export default function SettingsEditor() {
setSettings((s) => { setSettings((s) => {
const copy = JSON.parse(JSON.stringify(s)); const copy = JSON.parse(JSON.stringify(s));
const parts = path.split('.'); 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; let obj = copy;
for (let i = 0; i < parts.length - 1; i++) { for (let i = 0; i < parts.length - 1; i++) {
if (!obj[parts[i]]) obj[parts[i]] = {}; if (!obj[parts[i]]) obj[parts[i]] = {};

View File

@@ -1247,3 +1247,80 @@
display: none; 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", "name": "immich-folio",
"version": "0.18.0", "version": "0.18.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-folio", "name": "immich-folio",
"version": "0.18.0", "version": "0.18.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

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