Add icon set selector to admin page with persistent localStorage storage

- Appearance card in admin grid shows all 4 icon styles (color, flat, emoji, ascii)
  with live previews of all 6 statuses and a description for each
- Selected style persists across reloads via localStorage (LS_KEY alongside expandedMap)
- Stats overview card moved before Appearance card in admin grid
- Screenshot images added to README with titles before each image
This commit is contained in:
Ilia Sharin
2026-05-06 10:56:03 -04:00
parent 405b9816ad
commit 7e9231f0e6
3 changed files with 163 additions and 24 deletions
+47 -1
View File
@@ -399,7 +399,50 @@ function Kpi({ label, value, delta, unit, accent, deltaInverse }) {
); );
} }
function AdminPage({ data, onClose, history, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup }) { const ICON_STYLES = [
{ value: 'color', label: 'Color', hint: 'Filled circles with semantic colors' },
{ value: 'flat', label: 'Flat', hint: 'Minimal monochrome SVG squares' },
{ value: 'emoji', label: 'Emoji', hint: 'Native emoji glyphs' },
{ value: 'ascii', label: 'ASCII', hint: 'Plain text glyphs: [ ] [/] [!] …' },
];
function AppearanceCard({ tweaks, setTweak }) {
const current = tweaks?.status_style || 'color';
return (
<section className="card">
<div className="card-head"><h3>Appearance</h3></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{ICON_STYLES.map(opt => (
<label key={opt.value}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px', borderRadius: 8, cursor: 'pointer',
background: current === opt.value ? 'var(--bg-2)' : 'transparent',
border: `1px solid ${current === opt.value ? 'var(--rule)' : 'transparent'}`,
}}>
<input type="radio" name="icon-style" value={opt.value}
checked={current === opt.value}
onChange={() => setTweak('status_style', opt.value)}
style={{ accentColor: 'var(--accent)', flexShrink: 0 }}/>
<div style={{ display: 'flex', gap: 5, flexShrink: 0 }}>
{['open','in-progress','blocked','postponed','done','cancelled'].map(s => (
<StatusStyleContext.Provider key={s} value={opt.value}>
<StatusIcon status={s} size={14}/>
</StatusStyleContext.Provider>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ink)' }}>{opt.label}</span>
<span style={{ fontSize: 11.5, color: 'var(--ink-3)' }}>{opt.hint}</span>
</div>
</label>
))}
</div>
</section>
);
}
function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup }) {
const [previewBackup, setPreviewBackup] = useState(null); const [previewBackup, setPreviewBackup] = useState(null);
const counts = countByStatus(data.entries); const counts = countByStatus(data.entries);
@@ -490,6 +533,9 @@ function AdminPage({ data, onClose, history, onForceSave, onForceBackup, onCompa
{/* Stats overview */} {/* Stats overview */}
<StatsOverview dataStats={data.stats} mostActiveProject={data.stats.mostActiveProject} history={history}/> <StatsOverview dataStats={data.stats} mostActiveProject={data.stats.mostActiveProject} history={history}/>
{/* Appearance */}
<AppearanceCard tweaks={tweaks} setTweak={setTweak}/>
{/* Status mix */} {/* Status mix */}
<section className="card"> <section className="card">
<div className="card-head"><h3>Status mix</h3></div> <div className="card-head"><h3>Status mix</h3></div>
+11 -15
View File
@@ -12,10 +12,10 @@ function loadLocalState() {
} catch { return null; } } catch { return null; }
} }
// Only UI state (which rows are expanded) is persisted locally. // Only UI state (expanded rows, tweaks) is persisted locally.
// Backlog data always lives in backlog.md — never in localStorage. // Backlog data always lives in backlog.md — never in localStorage.
function saveLocalState(expandedMap) { function saveLocalState(expandedMap, tweaks) {
try { localStorage.setItem(LS_KEY, JSON.stringify({ expandedMap })); } catch { /* quota / private mode */ } try { localStorage.setItem(LS_KEY, JSON.stringify({ expandedMap, tweaks })); } catch { /* quota / private mode */ }
} }
// Empty starting state — used when there is no saved data and no seed data loaded. // Empty starting state — used when there is no saved data and no seed data loaded.
@@ -57,14 +57,8 @@ function App() {
const [confirm, setConfirm] = useStateMain(null); const [confirm, setConfirm] = useStateMain(null);
const [needsConnect, setNeedsConnect] = useStateMain(false); const [needsConnect, setNeedsConnect] = useStateMain(false);
const [tweaks, setTweak] = useTweaks({ const TWEAK_DEFAULTS = { accent_hue: 35, density: 'comfortable', show_ids: false, paper_texture: true, status_style: 'color', sort_mode: 'priority' };
accent_hue: 35, const [tweaks, setTweak] = useTweaks({ ...TWEAK_DEFAULTS, ...(loadLocalState()?.tweaks ?? {}) });
density: 'comfortable',
show_ids: false,
paper_texture: true,
status_style: 'color',
sort_mode: 'priority',
});
// Refs for async callbacks that need latest state without stale closures. // Refs for async callbacks that need latest state without stale closures.
const latestData = useRefMain(data); const latestData = useRefMain(data);
@@ -158,10 +152,10 @@ function App() {
return () => { cancelled = true; SyncPoller.stop(); }; return () => { cancelled = true; SyncPoller.stop(); };
}, []); }, []);
// Persist expanded/collapsed row state locally. Data itself lives in backlog.md. // Persist expanded/collapsed row state and tweaks locally. Data itself lives in backlog.md.
useEffectMain(() => { useEffectMain(() => {
saveLocalState(latestExpandedMap.current); saveLocalState(latestExpandedMap.current, tweaks);
}, [expandedMap]); }, [expandedMap, tweaks]);
const showToast = (msg, kind = 'ok') => { const showToast = (msg, kind = 'ok') => {
setToast({ msg, kind, t: Date.now() }); setToast({ msg, kind, t: Date.now() });
@@ -222,7 +216,7 @@ function App() {
const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/); const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/);
if (cm) SyncPoller.lastChecksum = cm[1]; if (cm) SyncPoller.lastChecksum = cm[1];
} }
saveLocalState(em); saveLocalState(em, tweaks);
isDirtyRef.current = false; isDirtyRef.current = false;
const now = new Date().toISOString(); const now = new Date().toISOString();
setSaveState({ status: 'saved', lastSaved: now }); setSaveState({ status: 'saved', lastSaved: now });
@@ -618,6 +612,8 @@ function App() {
<AdminPage <AdminPage
data={data} data={data}
history={data.history} history={data.history}
tweaks={tweaks}
setTweak={setTweak}
onClose={() => setView('backlog')} onClose={() => setView('backlog')}
onForceSave={() => triggerSave('Force-saved')} onForceSave={() => triggerSave('Force-saved')}
onForceBackup={() => triggerSave('Force-backed up')} onForceBackup={() => triggerSave('Force-backed up')}
+105 -8
View File
@@ -4975,10 +4975,97 @@ function Kpi({
className: "kpi-vs" className: "kpi-vs"
}, "vs prior"))); }, "vs prior")));
} }
const ICON_STYLES = [{
value: 'color',
label: 'Color',
hint: 'Filled circles with semantic colors'
}, {
value: 'flat',
label: 'Flat',
hint: 'Minimal monochrome SVG squares'
}, {
value: 'emoji',
label: 'Emoji',
hint: 'Native emoji glyphs'
}, {
value: 'ascii',
label: 'ASCII',
hint: 'Plain text glyphs: [ ] [/] [!] …'
}];
function AppearanceCard({
tweaks,
setTweak
}) {
const current = tweaks?.status_style || 'color';
return /*#__PURE__*/React.createElement("section", {
className: "card"
}, /*#__PURE__*/React.createElement("div", {
className: "card-head"
}, /*#__PURE__*/React.createElement("h3", null, "Appearance")), /*#__PURE__*/React.createElement("div", {
style: {
display: 'flex',
flexDirection: 'column',
gap: 6
}
}, ICON_STYLES.map(opt => /*#__PURE__*/React.createElement("label", {
key: opt.value,
style: {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 10px',
borderRadius: 8,
cursor: 'pointer',
background: current === opt.value ? 'var(--bg-2)' : 'transparent',
border: `1px solid ${current === opt.value ? 'var(--rule)' : 'transparent'}`
}
}, /*#__PURE__*/React.createElement("input", {
type: "radio",
name: "icon-style",
value: opt.value,
checked: current === opt.value,
onChange: () => setTweak('status_style', opt.value),
style: {
accentColor: 'var(--accent)',
flexShrink: 0
}
}), /*#__PURE__*/React.createElement("div", {
style: {
display: 'flex',
gap: 5,
flexShrink: 0
}
}, ['open', 'in-progress', 'blocked', 'postponed', 'done', 'cancelled'].map(s => /*#__PURE__*/React.createElement(StatusStyleContext.Provider, {
key: s,
value: opt.value
}, /*#__PURE__*/React.createElement(StatusIcon, {
status: s,
size: 14
})))), /*#__PURE__*/React.createElement("div", {
style: {
display: 'flex',
flexDirection: 'column',
gap: 1
}
}, /*#__PURE__*/React.createElement("span", {
style: {
fontSize: 13,
fontWeight: 500,
color: 'var(--ink)'
}
}, opt.label), /*#__PURE__*/React.createElement("span", {
style: {
fontSize: 11.5,
color: 'var(--ink-3)'
}
}, opt.hint))))));
}
function AdminPage({ function AdminPage({
data, data,
onClose, onClose,
history, history,
tweaks,
setTweak,
onForceSave, onForceSave,
onForceBackup, onForceBackup,
onCompact, onCompact,
@@ -5074,6 +5161,9 @@ function AdminPage({
dataStats: data.stats, dataStats: data.stats,
mostActiveProject: data.stats.mostActiveProject, mostActiveProject: data.stats.mostActiveProject,
history: history history: history
}), /*#__PURE__*/React.createElement(AppearanceCard, {
tweaks: tweaks,
setTweak: setTweak
}), /*#__PURE__*/React.createElement("section", { }), /*#__PURE__*/React.createElement("section", {
className: "card" className: "card"
}, /*#__PURE__*/React.createElement("div", { }, /*#__PURE__*/React.createElement("div", {
@@ -5207,12 +5297,13 @@ function loadLocalState() {
} }
} }
// Only UI state (which rows are expanded) is persisted locally. // Only UI state (expanded rows, tweaks) is persisted locally.
// Backlog data always lives in backlog.md — never in localStorage. // Backlog data always lives in backlog.md — never in localStorage.
function saveLocalState(expandedMap) { function saveLocalState(expandedMap, tweaks) {
try { try {
localStorage.setItem(LS_KEY, JSON.stringify({ localStorage.setItem(LS_KEY, JSON.stringify({
expandedMap expandedMap,
tweaks
})); }));
} catch {/* quota / private mode */} } catch {/* quota / private mode */}
} }
@@ -5291,13 +5382,17 @@ function App() {
const [itemDialog, setItemDialog] = useStateMain(null); const [itemDialog, setItemDialog] = useStateMain(null);
const [confirm, setConfirm] = useStateMain(null); const [confirm, setConfirm] = useStateMain(null);
const [needsConnect, setNeedsConnect] = useStateMain(false); const [needsConnect, setNeedsConnect] = useStateMain(false);
const [tweaks, setTweak] = useTweaks({ const TWEAK_DEFAULTS = {
accent_hue: 35, accent_hue: 35,
density: 'comfortable', density: 'comfortable',
show_ids: false, show_ids: false,
paper_texture: true, paper_texture: true,
status_style: 'color', status_style: 'color',
sort_mode: 'priority' sort_mode: 'priority'
};
const [tweaks, setTweak] = useTweaks({
...TWEAK_DEFAULTS,
...(loadLocalState()?.tweaks ?? {})
}); });
// Refs for async callbacks that need latest state without stale closures. // Refs for async callbacks that need latest state without stale closures.
@@ -5387,10 +5482,10 @@ function App() {
}; };
}, []); }, []);
// Persist expanded/collapsed row state locally. Data itself lives in backlog.md. // Persist expanded/collapsed row state and tweaks locally. Data itself lives in backlog.md.
useEffectMain(() => { useEffectMain(() => {
saveLocalState(latestExpandedMap.current); saveLocalState(latestExpandedMap.current, tweaks);
}, [expandedMap]); }, [expandedMap, tweaks]);
const showToast = (msg, kind = 'ok') => { const showToast = (msg, kind = 'ok') => {
setToast({ setToast({
msg, msg,
@@ -5457,7 +5552,7 @@ function App() {
const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/); const cm = content.match(/checksum:\s*(sha256:[a-f0-9]+)/);
if (cm) SyncPoller.lastChecksum = cm[1]; if (cm) SyncPoller.lastChecksum = cm[1];
} }
saveLocalState(em); saveLocalState(em, tweaks);
isDirtyRef.current = false; isDirtyRef.current = false;
const now = new Date().toISOString(); const now = new Date().toISOString();
setSaveState({ setSaveState({
@@ -5974,6 +6069,8 @@ function App() {
})))) : /*#__PURE__*/React.createElement(AdminPage, { })))) : /*#__PURE__*/React.createElement(AdminPage, {
data: data, data: data,
history: data.history, history: data.history,
tweaks: tweaks,
setTweak: setTweak,
onClose: () => setView('backlog'), onClose: () => setView('backlog'),
onForceSave: () => triggerSave('Force-saved'), onForceSave: () => triggerSave('Force-saved'),
onForceBackup: () => triggerSave('Force-backed up'), onForceBackup: () => triggerSave('Force-backed up'),