Add a notes UI for the per-Pokémon note field
The selection store already had a note field and setNote() (from the JSON export/import work) but nothing in the UI ever wrote to it. Adds: - PokemonDetail: an auto-resizing textarea under the Seen/Caught/Team row. Saves debounced (500ms) as you type, immediately on blur, and flushes any unsaved keystrokes on teardown so navigating away right after typing never loses them. A brief "Saved" flash confirms the write. - Card: a small 📝 mark on any card whose Pokémon has a note. - DexGrid: a "Notes" filter chip (same pattern as Caught/Favorites) to browse everything you've annotated. Notes already ride along in the existing settings/selection JSON export-import, so no changes needed there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
c9807127d6
commit
1711249dae
@ -73,7 +73,12 @@ Working app with a type-themed UI:
|
|||||||
per-dex progress, name/number filter, caught/favorite filters, plus a
|
per-dex progress, name/number filter, caught/favorite filters, plus a
|
||||||
filter drawer: type, generation, ability, egg group, minimum BST, and
|
filter drawer: type, generation, ability, egg group, minimum BST, and
|
||||||
"fully evolved only" (derived client-side from each species' evolves-from
|
"fully evolved only" (derived client-side from each species' evolves-from
|
||||||
link — no extra fetches).
|
link — no extra fetches). A "Notes" chip filters to Pokémon with a note;
|
||||||
|
cards with one get a small 📝 mark.
|
||||||
|
- **Notes** — a free-text note per Pokémon on its detail page (trade plans,
|
||||||
|
where you caught it, anything), autosaving as you type and flushing
|
||||||
|
immediately on blur or navigation so a quick tab-away never drops a
|
||||||
|
keystroke. Rides along in the existing JSON export/import.
|
||||||
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
|
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
|
||||||
snapshot; a pill switcher on the detail page rebuilds types / stats /
|
snapshot; a pill switcher on the detail page rebuilds types / stats /
|
||||||
matchups / abilities / learnset / artwork for the chosen form. Purely
|
matchups / abilities / learnset / artwork for the chosen form. Purely
|
||||||
|
|||||||
@ -49,6 +49,9 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
|||||||
},
|
},
|
||||||
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
|
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
|
||||||
el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
|
el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
|
||||||
|
state.note && state.note.trim()
|
||||||
|
? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝')
|
||||||
|
: null,
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'card__art' },
|
{ class: 'card__art' },
|
||||||
|
|||||||
@ -559,6 +559,20 @@
|
|||||||
.card.is-caught {
|
.card.is-caught {
|
||||||
border-color: color-mix(in srgb, var(--good) 45%, var(--border));
|
border-color: color-mix(in srgb, var(--good) 45%, var(--border));
|
||||||
}
|
}
|
||||||
|
.card__notemark {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 3;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.card {
|
.card {
|
||||||
animation: none;
|
animation: none;
|
||||||
@ -936,6 +950,55 @@
|
|||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
color: #ff4d6d;
|
color: #ff4d6d;
|
||||||
}
|
}
|
||||||
|
.pnote {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.pnote__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.pnote__status {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--good);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.pnote__status.is-visible {
|
||||||
|
animation: pnote-fade 1.6s ease forwards;
|
||||||
|
}
|
||||||
|
@keyframes pnote-fade {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
70% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.pnote__input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.2em;
|
||||||
|
max-height: 12em;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pnote__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--type-main, var(--accent));
|
||||||
|
}
|
||||||
.psheet__tabs {
|
.psheet__tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const FILTERS = [
|
|||||||
{ key: 'missing', label: 'Missing', test: (e) => !e.caught },
|
{ key: 'missing', label: 'Missing', test: (e) => !e.caught },
|
||||||
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
|
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
|
||||||
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
|
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
|
||||||
|
{ key: 'notes', label: 'Notes', test: (e) => !!(e.note && e.note.trim()) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SORTS = [
|
const SORTS = [
|
||||||
@ -407,13 +408,15 @@ export async function DexGrid() {
|
|||||||
let caught = 0;
|
let caught = 0;
|
||||||
let favorite = 0;
|
let favorite = 0;
|
||||||
let legendary = 0;
|
let legendary = 0;
|
||||||
|
let notes = 0;
|
||||||
for (const { species } of rows) {
|
for (const { species } of rows) {
|
||||||
const e = p[species.id];
|
const e = p[species.id];
|
||||||
if (e?.caught) caught++;
|
if (e?.caught) caught++;
|
||||||
if (e?.favorite) favorite++;
|
if (e?.favorite) favorite++;
|
||||||
if (species.isLegendary || species.isMythical) legendary++;
|
if (species.isLegendary || species.isMythical) legendary++;
|
||||||
|
if (e?.note && e.note.trim()) notes++;
|
||||||
}
|
}
|
||||||
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary };
|
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary, notes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshMeta() {
|
function refreshMeta() {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
|
|||||||
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
|
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
|
||||||
import { settings } from '../store/settings.js';
|
import { settings } from '../store/settings.js';
|
||||||
import { ui } from '../store/ui.js';
|
import { ui } from '../store/ui.js';
|
||||||
import { entry, toggle } from '../store/selection.js';
|
import { entry, toggle, setNote } from '../store/selection.js';
|
||||||
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
|
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
|
||||||
import { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
|
import { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
|
||||||
import { Sprite, spriteUrl } from '../components/Sprite.js';
|
import { Sprite, spriteUrl } from '../components/Sprite.js';
|
||||||
@ -280,6 +280,57 @@ export async function PokemonDetail(nationalId) {
|
|||||||
}
|
}
|
||||||
syncTrack();
|
syncTrack();
|
||||||
|
|
||||||
|
// ---- Personal note ------------------------------------------------
|
||||||
|
// Autosaves (debounced) as you type, and flushes immediately on blur or
|
||||||
|
// when leaving the page so a quick navigation never drops a keystroke.
|
||||||
|
const noteStatus = el('span', { class: 'pnote__status', 'aria-live': 'polite' });
|
||||||
|
let noteTimer = null;
|
||||||
|
let noteSaved = entry(nationalId).note || '';
|
||||||
|
function autosize(ta) {
|
||||||
|
ta.style.height = 'auto';
|
||||||
|
ta.style.height = `${ta.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
function flashSaved() {
|
||||||
|
noteStatus.textContent = 'Saved';
|
||||||
|
noteStatus.classList.remove('is-visible');
|
||||||
|
void noteStatus.offsetWidth; // restart the fade
|
||||||
|
noteStatus.classList.add('is-visible');
|
||||||
|
}
|
||||||
|
function saveNote(value) {
|
||||||
|
clearTimeout(noteTimer);
|
||||||
|
if (value === noteSaved) return;
|
||||||
|
noteSaved = value;
|
||||||
|
setNote(nationalId, value);
|
||||||
|
flashSaved();
|
||||||
|
}
|
||||||
|
const noteInput = el('textarea', {
|
||||||
|
class: 'pnote__input',
|
||||||
|
placeholder: 'Add a note — trade plans, where you caught it, anything…',
|
||||||
|
rows: 1,
|
||||||
|
oninput: (e) => {
|
||||||
|
autosize(e.target);
|
||||||
|
clearTimeout(noteTimer);
|
||||||
|
noteTimer = setTimeout(() => saveNote(e.target.value), 500);
|
||||||
|
},
|
||||||
|
onfocus: (e) => autosize(e.target),
|
||||||
|
onblur: (e) => saveNote(e.target.value),
|
||||||
|
});
|
||||||
|
noteInput.value = noteSaved;
|
||||||
|
// The textarea isn't attached (let alone laid out) yet when it's built —
|
||||||
|
// this view still has to be mounted, possibly through a view transition.
|
||||||
|
// Retry like the feed's scroll restore does, so a non-empty note that
|
||||||
|
// wraps to several lines starts fully visible instead of clipped to one.
|
||||||
|
requestAnimationFrame(() => autosize(noteInput));
|
||||||
|
setTimeout(() => autosize(noteInput), 120);
|
||||||
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => autosize(noteInput));
|
||||||
|
const noteBlock = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'pnote' },
|
||||||
|
el('label', { class: 'pnote__label', for: 'pnote-input' }, 'Note', noteStatus),
|
||||||
|
noteInput,
|
||||||
|
);
|
||||||
|
noteInput.id = 'pnote-input';
|
||||||
|
|
||||||
const allGames = st.versionGroup === 'all';
|
const allGames = st.versionGroup === 'all';
|
||||||
let statBars = [];
|
let statBars = [];
|
||||||
|
|
||||||
@ -690,6 +741,7 @@ export async function PokemonDetail(nationalId) {
|
|||||||
'div',
|
'div',
|
||||||
{ class: 'psheet' },
|
{ class: 'psheet' },
|
||||||
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
|
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
|
||||||
|
noteBlock,
|
||||||
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
||||||
body,
|
body,
|
||||||
),
|
),
|
||||||
@ -706,6 +758,7 @@ export async function PokemonDetail(nationalId) {
|
|||||||
const off = settings.subscribe(() => syncTrack());
|
const off = settings.subscribe(() => syncTrack());
|
||||||
const offTeam = team.subscribe(syncTeamBtn);
|
const offTeam = team.subscribe(syncTeamBtn);
|
||||||
onTeardown(view, () => {
|
onTeardown(view, () => {
|
||||||
|
saveNote(noteInput.value); // flush any unsaved keystrokes
|
||||||
off();
|
off();
|
||||||
offTeam();
|
offTeam();
|
||||||
offSwipe();
|
offSwipe();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user