diff options
Diffstat (limited to 'js/seed/src')
-rw-r--r-- | js/seed/src/api.js | 15 | ||||
-rw-r--r-- | js/seed/src/app.js | 165 | ||||
-rw-r--r-- | js/seed/src/dev.js | 74 | ||||
-rw-r--r-- | js/seed/src/state.js | 13 | ||||
-rw-r--r-- | js/seed/src/update.js | 22 | ||||
-rw-r--r-- | js/seed/src/view.js | 62 |
6 files changed, 351 insertions, 0 deletions
diff --git a/js/seed/src/api.js b/js/seed/src/api.js new file mode 100644 index 0000000..d50c644 --- /dev/null +++ b/js/seed/src/api.js @@ -0,0 +1,15 @@ +// api.js +// API fetch logic + +/** + * Fetch a Pokémon by name from the PokéAPI + * @param {string} name + * @returns {Promise<object>} Pokémon data + */ +export async function fetchPokemon(name) { + const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${encodeURIComponent(name.toLowerCase())}`); + if (!res.ok) { + throw new Error('Pokémon not found'); + } + return await res.json(); +} \ No newline at end of file diff --git a/js/seed/src/app.js b/js/seed/src/app.js new file mode 100644 index 0000000..34b4579 --- /dev/null +++ b/js/seed/src/app.js @@ -0,0 +1,165 @@ +// app.js +// Entrypoint for the app + +import { initialState, cloneState } from './state.js'; +import { update } from './update.js'; +import { view } from './view.js'; +import { fetchPokemon } from './api.js'; +import { initDevMode } from './dev.js'; + +const root = document.getElementById('app'); +let state = cloneState(initialState); +let dev; + +/** + * Entrypoint for the app. + * + * This file implements a minimal Elm-style architecture using only browser APIs and ES modules. + * - All state is immutable and updated by a pure update function. + * - The entire UI is re-rendered as a string on each state change for simplicity and predictability. + * - Event delegation is used to keep wiring minimal and functional. + * - No 3rd party code: everything is browser-native for cozy portability and clarity. + * + * Why this approach? + * - Functional, pure update/view logic is easier for me to reason about and test. + * - Re-rendering the whole UI avoids bugs from manual DOM updates and keeps state/UI in sync. + * - Minimal code and clear data flow make it easy to extend or adapt for new projects. + */ + +// Enable devMode if ?dev=1 is in the URL +/** + * devMode enables logging of all actions and state transitions for debugging. + * + * Why? This makes the app's state flow transparent, helping you understand and debug the app without extra tooling. + */ +const devMode = window.location.search.includes('dev=1'); + +/** + * Generalized render function for Elm-style apps. + * + * @param {Object} config - Render configuration + * @param {HTMLElement} config.root - Root DOM element + * @param {any} config.state - Current app state + * @param {Function} config.view - View function (state => HTML string) + * @param {Array} [config.events] - Array of { selector, event, handler } + * @param {Function} [config.postRender] - Optional function({ root, state }) for post-render logic + */ +function render({ root, state, view, events = [], postRender }) { + root.innerHTML = view(state); + events.forEach(({ selector, event, handler }) => { + const el = root.querySelector(selector); + if (el) el.addEventListener(event, handler); + }); + if (typeof postRender === 'function') { + postRender({ root, state }); + } +} + +// --- App-specific config for render --- +function postRender({ root, state }) { + const input = root.querySelector('#pokemon-query'); + const error = root.querySelector('.error'); + if (error) { + error.focus(); + } else if (input) { + input.focus(); + input.value = state.query; + input.setSelectionRange(input.value.length, input.value.length); + } +} + +function doRender() { + render({ + root, + state, + view, + events: [ + { selector: '#search-form', event: 'submit', handler: handleSubmit }, + { selector: '#pokemon-query', event: 'input', handler: handleInput }, + ], + postRender, + }); +} + +/** + * Dispatches an action to update state and re-render. + * + * Why centralize dispatch? This enforces a single source of truth for state changes, making the app predictable and easy to debug. + * + * Why log actions/state in devMode? This provides a transparent, time-travel-like view of app logic without needing any extra tooling. + */ +function dispatch(action) { + const prevState = state; + state = update(state, action); + if (devMode) { + dev.pushState(state); + console.groupCollapsed(`Action: ${action.type}`); + console.log('Payload:', action.payload); + console.log('Prev state:', prevState); + console.log('Next state:', state); + console.groupEnd(); + } + doRender(); +} + +/** + * Handles input events by dispatching an update action. + * + * Why not update state directly? All state changes go through dispatch/update for consistency and traceability. + */ +function handleInput(e) { + dispatch({ type: 'UPDATE_QUERY', payload: e.target.value }); +} + +/** + * Handles form submission, triggers async fetch, and dispatches state updates. + * + * Why handle async here? Keeps update/view pure and centralizes side-effect. + */ +async function handleSubmit(e) { + e.preventDefault(); + if (!state.query.trim()) return; + dispatch({ type: 'FETCH_START' }); + try { + const data = await fetchPokemon(state.query.trim()); + dispatch({ type: 'FETCH_SUCCESS', payload: data }); + } catch (err) { + dispatch({ type: 'FETCH_ERROR', payload: err.message }); + } +} + +// Initial render +doRender(); + +// After devMode is set +if (devMode) { + dev = initDevMode({ + getState: () => state, + setState: s => { state = s; }, + render: doRender + }); +} + +function updateHistoryInfo() { + if (!devMode || !dev) return; + dev.update(); +} + +function setHistoryPointer(idx) { + const info = dev.getHistoryInfo(); + if (idx < 1 || idx > info.length) return; + const newState = dev.setPointer(idx - 1); + if (newState) { + state = newState; + doRender(); + updateHistoryInfo(); + } +} + +function handleSliderChange(e) { + setHistoryPointer(Number(e.target.value)); +} + +function handleStepperChange(e) { + setHistoryPointer(Number(e.target.value)); +} \ No newline at end of file diff --git a/js/seed/src/dev.js b/js/seed/src/dev.js new file mode 100644 index 0000000..ee1a6e7 --- /dev/null +++ b/js/seed/src/dev.js @@ -0,0 +1,74 @@ +// devMode.js +// Minimal, single-file dev mode with scriptable console API + +/** + * Initialize dev mode: exposes a scriptable API for stepping through state history. + * @param {object} opts + * @param {function} opts.getState - returns current app state + * @param {function} opts.setState - sets app state + * @param {function} opts.render - triggers app re-render + */ +export function initDevMode({ getState, setState, render }) { + let history = []; + let pointer = -1; + let firstLoad = true; + + function pushState(state) { + if (pointer < history.length - 1) history = history.slice(0, pointer + 1); + history.push(clone(state)); + pointer = history.length - 1; + logInstructions(); + } + function goTo(idx) { + if (idx < 0 || idx >= history.length) return; + pointer = idx; + setState(clone(history[pointer])); + render(); + logInstructions(); + } + function next() { + if (pointer < history.length - 1) goTo(pointer + 1); + } + function prev() { + if (pointer > 0) goTo(pointer - 1); + } + function get() { + return history[pointer]; + } + function clone(obj) { + return JSON.parse(JSON.stringify(obj)); + } + function table(obj) { + console.table(dev.history); + } + function logInstructions() { + if (firstLoad) { + console.log('[DevMode] State history debugger'); + console.log('Usage:'); + console.log('- dev.next() // step forward'); + console.log('- dev.prev() // step backward'); + console.log('- dev.goTo(n) // jump to state n (1-based)'); + console.log('- dev.get() // get current state'); + console.log('- dev.table() // display history as a table'); + console.log('- dev.history // array of all states'); + console.log('- dev.pointer // current pointer (0-based)'); + firstLoad = false; + } + } + + // Expose API globally for console use + window.dev = { + next, + prev, + goTo, + get, + table, + get pointer() { return pointer; }, + get history() { return history.slice(); }, + }; + + // Initial state + pushState(getState()); + + return { pushState }; +} \ No newline at end of file diff --git a/js/seed/src/state.js b/js/seed/src/state.js new file mode 100644 index 0000000..d1bad17 --- /dev/null +++ b/js/seed/src/state.js @@ -0,0 +1,13 @@ +// state.js +// App state definition and helpers + +export const initialState = { + query: '', + pokemon: null, + loading: false, + error: null +}; + +export function cloneState(state) { + return JSON.parse(JSON.stringify(state)); +} \ No newline at end of file diff --git a/js/seed/src/update.js b/js/seed/src/update.js new file mode 100644 index 0000000..2f6c13b --- /dev/null +++ b/js/seed/src/update.js @@ -0,0 +1,22 @@ +// update.js +// Pure update function + +/** + * @param {object} state - Current state + * @param {object} action - { type, payload } + * @returns {object} new state + */ +export function update(state, action) { + switch (action.type) { + case 'UPDATE_QUERY': + return { ...state, query: action.payload, error: null }; + case 'FETCH_START': + return { ...state, loading: true, error: null, pokemon: null }; + case 'FETCH_SUCCESS': + return { ...state, loading: false, error: null, pokemon: action.payload }; + case 'FETCH_ERROR': + return { ...state, loading: false, error: action.payload, pokemon: null }; + default: + return state; + } +} \ No newline at end of file diff --git a/js/seed/src/view.js b/js/seed/src/view.js new file mode 100644 index 0000000..5feef6e --- /dev/null +++ b/js/seed/src/view.js @@ -0,0 +1,62 @@ +// view.js +// Pure view functions + +/** + * Pure view functions for the application. + * + * Why pure functions returning HTML strings? + * - Keeps rendering logic stateless and easy to test. + * - Ensures the UI is always a direct function of state, avoiding UI bugs from incremental DOM updates. + * - Using template literals is minimal and browser-native, with no dependencies. + * + * Why escape output? + * - Prevents XSS and ensures all user/content data is safely rendered. + * + * Why semantic/accessible HTML? + * - Ensures the app is usable for all users, including those using assistive tech, and is easy to reason about. + */ +/** + * Render the app UI as an HTML string + * @param {object} state + * @returns {string} + */ +export function view(state) { + return ` + <h1>PokéDex</h1> + <container> + <form id="search-form" autocomplete="off"> + <label for="pokemon-query">Pokémon Name (or number)</label> + <input id="pokemon-query" type="text" value="${escape(state.query)}" placeholder="e.g. pikachu" aria-label="Pokémon Name" required /> + <button type="submit" ${state.loading ? 'disabled' : ''}>${state.loading ? 'Loading...' : 'Search'}</button> + </form> + ${state.error ? `<div class="error" role="alert" tabindex="-1">${escape(state.error)}</div>` : ''} + ${state.pokemon ? renderResult(state.pokemon) : ''} + </container> + `; +} + +function renderResult(pokemon) { + return ` + <div class="result"> + <h2>${capitalize(pokemon.name)} (#${pokemon.id})</h2> + <img class="pokemon-sprite" src="${pokemon.sprites.front_default}" alt="${escape(pokemon.name)} sprite" /> + <ul> + <li>Type: <b>${pokemon.types.map(t => capitalize(escape(t.type.name))).join(', ')}</b></li> + <li>Height: <b>${pokemon.height / 10} m</b></li> + <li>Weight: <b>${pokemon.weight / 10} kg</b></li> + </ul> + </div> + `; +} + +function escape(str) { + /** + * Escapes HTML special characters to prevent XSS. + * + * Why escape here? Keeps all rendering safe by default, so no accidental injection is possible. + */ + return String(str).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} \ No newline at end of file |