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:
+47
-1
@@ -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
@@ -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
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user