feat: photo sync (iCloud + Amazon import), larger gear icon, Luma branding

- Settings: iCloud Photos section with icloudpd-backed sync — Apple ID,
  password, download path, cookie dir, live log output via SSE
- Settings: Amazon Photos one-time import — path to extracted data export,
  Add & Index button wires it directly into Watch Folders
- scripts/amazon_sync.py: stdlib-only downloader using Netscape cookie file
- server: POST /api/sync/icloud, /api/sync/amazon, GET /api/sync/stream
  (SSE log streaming matching ingest stream pattern)
- db: icloud/amazon setting keys; getSettings handles bool + string fields
- Gear icon: fontSize 16→20, button 38×38, color #555→#888
- favicon.svg: 6-blade aperture icon in Luma blue
- Browser tab title: shpm → Luma
- Dockerfile: installs icloudpd via pip3, copies scripts/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 09:30:43 -04:00
1 parent 5ff7f38b85
commit 4ff5a6c8ca
9 files changed
+569 -17

No files matched your search

+2 -1
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>shpm</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Luma</title>
</head>
<body>
<div id="root"></div>
+23
View File
@@ -0,0 +1,23 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="100" height="100" rx="22" fill="#0d0d0d"/>
<!-- Outer lens ring -->
<circle cx="50" cy="50" r="40" fill="none" stroke="#4d9eff" stroke-width="1.5" opacity="0.22"/>
<!-- 6 aperture blades -->
<g transform="translate(50,50)" fill="#4d9eff">
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(0)"/>
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(60)"/>
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(120)"/>
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(180)"/>
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(240)"/>
<polygon points="-11,-33 11,-33 5,-13 -5,-13" transform="rotate(300)"/>
</g>
<!-- Center opening -->
<circle cx="50" cy="50" r="10" fill="#0d0d0d"/>
<!-- Lens highlight -->
<ellipse cx="43" cy="37" rx="5" ry="3.5" fill="white" opacity="0.13" transform="rotate(-30 43 37)"/>
</svg>

After

Width:  |  Height:  |  Size: 1023 B

+3 -3
View File
@@ -149,9 +149,9 @@ export default function App() {
style={{
background: showSettings ? '#1e2a3a' : 'transparent',
border: 'none', borderRadius: 6,
color: showSettings ? '#7ab8f5' : '#555',
cursor: 'pointer', fontSize: 16,
width: 32, height: 32,
color: showSettings ? '#7ab8f5' : '#888',
cursor: 'pointer', fontSize: 20,
width: 38, height: 38,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
+5
View File
@@ -104,6 +104,11 @@ export function clearDismissals() {
return api('/api/admin/clear-dismissals', { method: 'POST' });
}
// Photo sync
export function triggerSync(source) {
return api(`/api/sync/${source}`, { method: 'POST' });
}
// Duplicates
export function fetchDuplicates() {
return api('/api/duplicates');
+267 -1
View File
@@ -2,15 +2,24 @@ import { useState, useEffect } from 'react';
import {
fetchSettings, patchSettings,
fetchWatchFolders, addWatchFolder, removeWatchFolder, toggleWatchFolder,
clearDismissals, fetchDirBrowser,
clearDismissals, fetchDirBrowser, triggerSync,
} from '../api/photos.js';
const EMPTY_SYNC = {
icloud: { running: false, lines: [], exitCode: null, startedAt: null, error: null },
amazon: { running: false, lines: [], exitCode: null, startedAt: null, error: null },
};
export default function SettingsPanel({ onClose }) {
const [settings, setSettings] = useState(null);
const [folders, setFolders] = useState([]);
const [newPath, setNewPath] = useState('');
const [saving, setSaving] = useState(false);
const [cleared, setCleared] = useState(false);
const [syncStatus, setSyncStatus] = useState(EMPTY_SYNC);
const [syncError, setSyncError] = useState({});
const [amazonPath, setAmazonPath] = useState('');
const [amazonAdded, setAmazonAdded] = useState(false);
// Folder browser
const [browseOpen, setBrowseOpen] = useState(false);
@@ -22,6 +31,12 @@ export default function SettingsPanel({ onClose }) {
fetchWatchFolders().then(setFolders);
}, []);
useEffect(() => {
const es = new EventSource('/api/sync/stream');
es.onmessage = (e) => setSyncStatus(JSON.parse(e.data));
return () => es.close();
}, []);
// Debounced setting save
async function handleSetting(key, value) {
const updated = { ...settings, [key]: value };
@@ -85,6 +100,25 @@ export default function SettingsPanel({ onClose }) {
setTimeout(() => setCleared(false), 2000);
}
async function handleSync(source) {
setSyncError(prev => ({ ...prev, [source]: null }));
try {
await triggerSync(source);
} catch (e) {
setSyncError(prev => ({ ...prev, [source]: e.message }));
}
}
async function handleAmazonImport() {
const p = amazonPath.trim();
if (!p) return;
const updated = await addWatchFolder(p);
setFolders(updated);
setAmazonPath('');
setAmazonAdded(true);
setTimeout(() => setAmazonAdded(false), 3000);
}
return (
// Backdrop
<div
@@ -314,6 +348,93 @@ export default function SettingsPanel({ onClose }) {
</p>
</Section>
{/* ── Photo Sync ── */}
<Section title="Photo Sync">
{/* iCloud Photos */}
<SyncSourceHeader label="iCloud Photos" />
<ToggleSetting
label="Enabled"
value={!!settings.icloud_enabled}
onChange={v => handleSetting('icloud_enabled', v)}
/>
{settings.icloud_enabled && (
<>
<TextSetting
label="Apple ID"
value={settings.icloud_apple_id}
placeholder="you@icloud.com"
onChange={v => handleSetting('icloud_apple_id', v)}
/>
<TextSetting
label="Password"
type="password"
value={settings.icloud_password}
placeholder="optional after first auth"
onChange={v => handleSetting('icloud_password', v)}
/>
<TextSetting
label="Download to"
value={settings.icloud_dest}
placeholder="/photos/icloud"
onChange={v => handleSetting('icloud_dest', v)}
/>
<TextSetting
label="Cookie dir"
value={settings.icloud_cookie_dir}
placeholder="/data/icloud-cookies"
onChange={v => handleSetting('icloud_cookie_dir', v)}
/>
<SyncControls
job={syncStatus.icloud}
error={syncError.icloud}
onSync={() => handleSync('icloud')}
/>
<p style={{ fontSize: 11, color: '#3a3a3a', padding: '2px 18px 4px', lineHeight: 1.5 }}>
First run: <code style={{ color: '#555' }}>docker exec &lt;container&gt; icloudpd --username &lt;id&gt; --auth-only --cookie-directory /data/icloud-cookies</code>
</p>
</>
)}
{/* Amazon Photos */}
<SyncSourceHeader label="Amazon Photos — one-time import" />
<p style={{ fontSize: 11, color: '#3a3a3a', padding: '0 18px 8px', lineHeight: 1.6 }}>
Request your library at amazon.com → Account → Request Your Data → Amazon Photos.
Extract the ZIP, copy to your server, then add the folder path here.
</p>
<div style={{ display: 'flex', gap: 6, padding: '0 18px 6px' }}>
<input
value={amazonPath}
onChange={e => setAmazonPath(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAmazonImport()}
placeholder="/photos/amazon-export"
style={{
flex: 1, background: '#1a1a1a', border: '1px solid #2a2a2a',
borderRadius: 5, color: '#ccc', fontSize: 11,
padding: '5px 8px', outline: 'none', fontFamily: 'monospace',
}}
/>
<button
onClick={handleAmazonImport}
disabled={!amazonPath.trim()}
style={{
background: amazonAdded ? '#1a2f1a' : amazonPath.trim() ? '#1e2f45' : '#181818',
border: '1px solid ' + (amazonAdded ? '#2a4a2a' : amazonPath.trim() ? '#2a4a6a' : '#222'),
borderRadius: 5,
color: amazonAdded ? '#4a9a4a' : amazonPath.trim() ? '#7ab8f5' : '#333',
cursor: amazonPath.trim() ? 'pointer' : 'default',
padding: '5px 12px', fontSize: 12, whiteSpace: 'nowrap',
}}
>
{amazonAdded ? '✓ Added' : 'Add & Index'}
</button>
</div>
<p style={{ fontSize: 11, color: '#3a3a3a', padding: '4px 18px 0', lineHeight: 1.5 }}>
Also add the iCloud download folder to Watch Folders once sync runs.
</p>
</Section>
{/* ── Danger Zone ── */}
<Section title="Danger Zone">
<div style={{ padding: '4px 18px' }}>
@@ -389,3 +510,148 @@ function DangerBtn({ onClick, children }) {
</button>
);
}
function SyncSourceHeader({ label }) {
return (
<div style={{
fontSize: 11, color: '#555', fontWeight: 600,
padding: '10px 18px 4px', borderTop: '1px solid #1a1a1a', marginTop: 6,
}}>
{label}
</div>
);
}
function ToggleSetting({ label, value, onChange }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 18px' }}>
<span style={{ flex: 1, fontSize: 13, color: '#aaa' }}>{label}</span>
<button
onClick={() => onChange(!value)}
style={{
width: 36, height: 20, borderRadius: 10, flexShrink: 0,
background: value ? '#1e3a5f' : '#1a1a1a',
border: '1px solid ' + (value ? '#2a5a9f' : '#2a2a2a'),
cursor: 'pointer', padding: 0, position: 'relative',
}}
>
<span style={{
position: 'absolute', top: 2,
left: value ? 18 : 2,
width: 14, height: 14, borderRadius: '50%',
background: value ? '#7ab8f5' : '#444',
transition: 'left 0.15s',
display: 'block',
}} />
</button>
</div>
);
}
function TextSetting({ label, value, placeholder, type = 'text', onChange }) {
const [local, setLocal] = useState(value ?? '');
// Sync if parent value changes (e.g. after save)
useState(() => setLocal(value ?? ''));
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 18px' }}>
<span style={{ flex: 1, fontSize: 13, color: '#aaa', flexShrink: 0 }}>{label}</span>
<input
type={type}
value={local}
placeholder={placeholder}
onChange={e => setLocal(e.target.value)}
onBlur={() => onChange(local)}
onKeyDown={e => e.key === 'Enter' && onChange(local)}
style={{
width: 170, background: '#1a1a1a', border: '1px solid #2a2a2a',
borderRadius: 5, color: '#ccc', fontSize: 11,
padding: '4px 8px', outline: 'none', fontFamily: 'monospace',
}}
/>
</div>
);
}
function SyncControls({ job, error, onSync }) {
const [showLog, setShowLog] = useState(false);
// Auto-show log while running
const wasRunning = job.running;
if (wasRunning && !showLog) setShowLog(true);
const lastRunLabel = job.startedAt
? new Date(job.startedAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: null;
return (
<div style={{ padding: '6px 18px 2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
onClick={onSync}
disabled={job.running}
style={{
background: job.running ? '#141414' : '#1e2f45',
border: '1px solid ' + (job.running ? '#222' : '#2a4a6a'),
borderRadius: 5, color: job.running ? '#444' : '#7ab8f5',
cursor: job.running ? 'default' : 'pointer',
padding: '5px 14px', fontSize: 12,
}}
>
{job.running ? 'Syncing…' : 'Sync Now'}
</button>
{job.running && (
<span style={{
width: 7, height: 7, borderRadius: '50%',
background: '#4d9eff', display: 'inline-block',
animation: 'pulse-dot 1.4s ease-in-out infinite',
}} />
)}
{lastRunLabel && !job.running && (
<span style={{ fontSize: 11, color: '#3a3a3a' }}>
Last: {lastRunLabel}
{job.exitCode !== null && (
<span style={{ color: job.exitCode === 0 ? '#4a7a4a' : '#7a3a3a', marginLeft: 6 }}>
{job.exitCode === 0 ? '✓' : `✗ (${job.exitCode})`}
</span>
)}
</span>
)}
{job.lines.length > 0 && (
<button
onClick={() => setShowLog(s => !s)}
style={{
background: 'none', border: 'none', color: '#3a3a3a',
cursor: 'pointer', fontSize: 11, marginLeft: 'auto', padding: 0,
}}
>
{showLog ? 'hide log' : 'show log'}
</button>
)}
</div>
{error && (
<p style={{ fontSize: 11, color: '#884444', margin: '4px 0 0' }}>{error}</p>
)}
{showLog && job.lines.length > 0 && (
<div style={{
marginTop: 6, background: '#080808', border: '1px solid #1a1a1a',
borderRadius: 4, padding: '6px 8px',
maxHeight: 140, overflowY: 'auto',
fontFamily: 'monospace', fontSize: 10, color: '#555',
lineHeight: 1.6,
}}>
{job.lines.slice(-60).map((line, i) => (
<div key={i} style={{ color: line.startsWith('Error') || line.startsWith(' Error') ? '#884444' : '#555' }}>
{line}
</div>
))}
</div>
)}
</div>
);
}