From a1a4c6a5fa35332b35f05c9b1ca2a790ca81c746 Mon Sep 17 00:00:00 2001 From: Timur Shemsedinov Date: Fri, 12 Jun 2026 02:26:08 +0300 Subject: [PATCH 1/3] Implementation with sonnet 4.6 --- routes/profile.js | 199 ++++++++++ routes/profiles.js | 63 ++++ routes/static.js | 47 +++ server.js | 371 ++++++------------- shared/profile-domain.mjs | 54 ++- static/api.mjs | 74 ++++ static/app.mjs | 1 - static/components/profile-app.html | 21 ++ static/components/profile-app.mjs | 123 +++--- static/components/profile-create-dialog.html | 14 + static/components/profile-create-dialog.mjs | 47 +-- static/components/profile-directory.html | 17 + static/components/profile-directory.mjs | 70 +--- static/components/profile-field.html | 36 ++ static/components/profile-field.mjs | 112 ++---- static/components/profile-form.html | 48 +++ static/components/profile-form.mjs | 175 ++++----- static/components/profile-item.html | 24 ++ static/components/profile-item.mjs | 70 +--- static/components/profile-list.mjs | 13 +- static/components/profile-search.html | 11 + static/components/profile-search.mjs | 50 +-- static/components/profile-summary.html | 41 ++ static/components/profile-summary.mjs | 85 +---- static/components/validation-message.html | 6 + static/components/validation-message.mjs | 20 +- static/index.html | 1 + static/styles.css | 29 ++ 28 files changed, 999 insertions(+), 823 deletions(-) create mode 100644 routes/profile.js create mode 100644 routes/profiles.js create mode 100644 routes/static.js create mode 100644 static/api.mjs create mode 100644 static/components/profile-app.html create mode 100644 static/components/profile-create-dialog.html create mode 100644 static/components/profile-directory.html create mode 100644 static/components/profile-field.html create mode 100644 static/components/profile-form.html create mode 100644 static/components/profile-item.html create mode 100644 static/components/profile-search.html create mode 100644 static/components/profile-summary.html create mode 100644 static/components/validation-message.html diff --git a/routes/profile.js b/routes/profile.js new file mode 100644 index 0000000..7b1daaf --- /dev/null +++ b/routes/profile.js @@ -0,0 +1,199 @@ +import { readdir, readFile, writeFile, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { buildProfileState } from '../shared/profile-domain.mjs'; + +const USERNAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/i; + +const usernameToPath = (username, profileDir) => { + if (!USERNAME_RE.test(username)) return null; + return path.join(profileDir, `${username}.json`); +}; + +const readProfile = async (username, profileDir) => { + const filePath = usernameToPath(username, profileDir); + if (!filePath) return null; + const raw = await readFile(filePath, 'utf8'); + return JSON.parse(raw); +}; + +const saveProfileState = async (username, incoming, profileDir) => { + const filePath = usernameToPath(username, profileDir); + if (!filePath) { + return { + ok: false, + status: 422, + payload: { ok: false, errors: { id: 'Invalid username' } }, + }; + } + + const merged = { ...(incoming || {}), id: username }; + const state = buildProfileState(merged); + + if (!state.valid) { + return { + ok: false, + status: 422, + payload: { + ok: false, + profile: state.profile, + computed: state.computed, + errors: state.errors, + valid: false, + }, + }; + } + + await writeFile(filePath, JSON.stringify(state.profile, null, 2), 'utf8'); + return { + ok: true, + status: 200, + payload: { + ok: true, + profile: state.profile, + computed: state.computed, + errors: state.errors, + valid: true, + }, + }; +}; + +const getProfile = async ( + req, + res, + { username }, + { profileDir, sendJson, serveIndex, staticDir }, +) => { + if (!USERNAME_RE.test(username)) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + const accept = req.headers.accept || ''; + if (accept.includes('text/html') && !accept.includes('application/json')) { + await serveIndex(res, staticDir); + return; + } + try { + const source = await readProfile(username, profileDir); + const state = buildProfileState(source); + sendJson(res, 200, { + ok: true, + profile: state.profile, + computed: state.computed, + errors: state.errors, + valid: state.valid, + }); + } catch (error) { + if (error?.code === 'ENOENT') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + if (error instanceof SyntaxError) { + res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Corrupt profile JSON'); + return; + } + throw error; + } +}; + +const updateProfile = async ( + req, + res, + { username }, + { profileDir, sendJson, parseJsonBody }, +) => { + if (!USERNAME_RE.test(username)) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + let body; + try { + body = await parseJsonBody(req); + } catch (error) { + if (error.message === 'INVALID_JSON') { + res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Invalid JSON'); + return; + } + throw error; + } + const result = await saveProfileState(username, body, profileDir); + sendJson(res, result.status, result.payload); +}; + +const removeProfile = async ( + req, + res, + { username }, + { profileDir, sendJson }, +) => { + const target = usernameToPath(username, profileDir); + if (!target) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + try { + await unlink(target); + sendJson(res, 200, { ok: true }); + } catch (error) { + if (error?.code === 'ENOENT') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + throw error; + } +}; + +const listProfiles = async (req, res, _params, { profileDir, sendJson }) => { + const url = new URL(req.url, 'http://localhost'); + const nameFilter = url.searchParams.get('name')?.toLowerCase() || ''; + const emailFilter = url.searchParams.get('email')?.toLowerCase() || ''; + + const files = await readdir(profileDir).catch(() => []); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + const profiles = await Promise.all( + jsonFiles.map(async (f) => { + try { + const raw = await readFile(path.join(profileDir, f), 'utf8'); + const { profile, computed } = buildProfileState(JSON.parse(raw)); + return { + id: profile.id, + displayName: computed.displayName, + email: profile.email, + }; + } catch { + return null; + } + }), + ); + + const items = profiles + .filter(Boolean) + .filter( + (p) => !nameFilter || p.displayName.toLowerCase().includes(nameFilter), + ) + .filter((p) => !emailFilter || p.email.toLowerCase().includes(emailFilter)) + .sort((a, b) => a.id.localeCompare(b.id)); + + sendJson(res, 200, { ok: true, items }); +}; + +export { USERNAME_RE, usernameToPath, readProfile }; +export const routes = [ + { pattern: '/profile', handlers: { GET: listProfiles } }, + { + pattern: '/profile/:username', + handlers: { + GET: getProfile, + POST: updateProfile, + PUT: updateProfile, + DELETE: removeProfile, + }, + }, +]; diff --git a/routes/profiles.js b/routes/profiles.js new file mode 100644 index 0000000..03e54ef --- /dev/null +++ b/routes/profiles.js @@ -0,0 +1,63 @@ +import { buildProfileState } from '../shared/profile-domain.mjs'; +import { stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { USERNAME_RE } from './profile.js'; + +const createProfile = async ( + req, + res, + _params, + { profileDir, sendJson, parseJsonBody }, +) => { + let body; + try { + body = await parseJsonBody(req); + } catch (error) { + if (error.message === 'INVALID_JSON') { + res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Invalid JSON'); + return; + } + throw error; + } + + const requestedId = typeof body.id === 'string' ? body.id.trim() : ''; + if (!USERNAME_RE.test(requestedId)) { + sendJson(res, 422, { ok: false, errors: { id: 'Invalid username' } }); + return; + } + + const filePath = path.join(profileDir, `${requestedId}.json`); + try { + await stat(filePath); + sendJson(res, 409, { ok: false, error: 'Profile already exists' }); + return; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + + const merged = { ...body, id: requestedId }; + const state = buildProfileState(merged); + + if (!state.valid) { + sendJson(res, 422, { + ok: false, + profile: state.profile, + computed: state.computed, + errors: state.errors, + valid: false, + }); + return; + } + + await writeFile(filePath, JSON.stringify(state.profile, null, 2), 'utf8'); + sendJson(res, 200, { + ok: true, + profile: state.profile, + computed: state.computed, + errors: state.errors, + valid: true, + }); +}; + +export default { POST: createProfile }; diff --git a/routes/static.js b/routes/static.js new file mode 100644 index 0000000..1b71236 --- /dev/null +++ b/routes/static.js @@ -0,0 +1,47 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.mjs': 'application/javascript; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', +}; + +const TEMPLATES_PLACEHOLDER = ''; + +const serveFile = async (res, filePath) => { + try { + const data = await readFile(filePath); + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': contentType }); + res.end(data); + } catch (error) { + if (error?.code === 'ENOENT') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + throw error; + } +}; + +const serveIndex = async (res, staticDir) => { + const componentsDir = path.join(staticDir, 'components'); + const [indexHtml, files] = await Promise.all([ + readFile(path.join(staticDir, 'index.html'), 'utf8'), + readdir(componentsDir), + ]); + const htmlFiles = files.filter((f) => f.endsWith('.html')).sort(); + const parts = await Promise.all( + htmlFiles.map((f) => readFile(path.join(componentsDir, f), 'utf8')), + ); + const html = indexHtml.replace(TEMPLATES_PLACEHOLDER, parts.join('\n')); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(html); +}; + +export { serveFile, serveIndex }; diff --git a/server.js b/server.js index 035e9b1..4b3425d 100644 --- a/server.js +++ b/server.js @@ -1,315 +1,162 @@ import { createServer } from 'node:http'; -import { readFile, readdir, writeFile, unlink, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { buildProfileState } from './shared/profile-domain.mjs'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const PROJECT_ROOT = __dirname; -const STATIC_DIR = path.join(PROJECT_ROOT, 'static'); -const SHARED_DIR = path.join(PROJECT_ROOT, 'shared'); -const PROFILE_DIR = path.join(PROJECT_ROOT, 'data', 'profile'); +import { readdir } from 'node:fs/promises'; +import { serveFile, serveIndex } from './routes/static.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STATIC_DIR = path.join(__dirname, 'static'); +const SHARED_DIR = path.join(__dirname, 'shared'); +const PROFILE_DIR = path.join(__dirname, 'data', 'profile'); +const ROUTES_DIR = path.join(__dirname, 'routes'); const HOST = '127.0.0.1'; const PORT = 8000; -const MIME_TYPES = { - '.html': 'text/html; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.mjs': 'application/javascript; charset=utf-8', - '.js': 'application/javascript; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.txt': 'text/plain; charset=utf-8', -}; - -const USERNAME_RE = /^[a-z0-9][a-z0-9-]{1,63}$/i; +const JSON_HEADERS = { 'Content-Type': 'application/json; charset=utf-8' }; +const TEXT_HEADERS = { 'Content-Type': 'text/plain; charset=utf-8' }; const sendJson = (res, status, payload) => { - res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.writeHead(status, JSON_HEADERS); res.end(JSON.stringify(payload)); }; -const sendText = (res, status, text) => { - res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end(text); +const parseJsonBody = async (req) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString(); + return JSON.parse(raw); }; const insideProject = (candidatePath) => { - const rel = path.relative(PROJECT_ROOT, candidatePath); + const rel = path.relative(__dirname, candidatePath); return rel && !rel.startsWith('..') && !path.isAbsolute(rel); }; const safeJoin = (base, requestPath) => { const cleaned = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, ''); const full = path.join(base, cleaned); - if (!insideProject(full)) return null; - return full; + return insideProject(full) ? full : null; }; -const parseJsonBody = async (req) => { - let raw = ''; - for await (const chunk of req) { - raw += chunk; - if (raw.length > 2_000_000) { - throw new Error('REQUEST_TOO_LARGE'); +const ctx = { + profileDir: PROFILE_DIR, + staticDir: STATIC_DIR, + sendJson, + parseJsonBody, + serveFile, + serveIndex, +}; + +const scanRouteFiles = async (dir, base = dir) => { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await scanRouteFiles(full, base))); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(path.relative(base, full)); } } - if (!raw.trim()) return {}; - try { - return JSON.parse(raw); - } catch { - throw new Error('INVALID_JSON'); - } + return files; }; -const usernameToPath = (username) => { - if (!USERNAME_RE.test(username)) return null; - return path.join(PROFILE_DIR, `${username}.json`); +const toPattern = (relPath) => { + const segments = relPath + .replace(/\.js$/, '') + .split(path.sep) + .filter((s) => s !== 'index') + .map((s) => + s.startsWith('[') && s.endsWith(']') ? `:${s.slice(1, -1)}` : s, + ); + return `/${segments.join('/')}`; }; -const readProfile = async (username) => { - const target = usernameToPath(username); - if (!target) return null; - const raw = await readFile(target, 'utf8'); - return JSON.parse(raw); +const matchPattern = (pattern, pathname) => { + const pp = pattern.split('/'); + const up = pathname.split('/'); + if (pp.length !== up.length) return null; + const params = {}; + for (let i = 0; i < pp.length; i++) { + if (pp[i].startsWith(':')) params[pp[i].slice(1)] = up[i]; + else if (pp[i] !== up[i]) return null; + } + return params; }; -const readDirectoryItems = async () => { - const files = await readdir(PROFILE_DIR); - return files.filter((file) => file.endsWith('.json')); +const loadRoutes = async () => { + const files = (await scanRouteFiles(ROUTES_DIR)).sort(); + const table = []; + for (const relPath of files) { + const mod = await import(path.join(ROUTES_DIR, relPath)); + if (Array.isArray(mod.routes)) { + table.push(...mod.routes); + } else if (mod.default && typeof mod.default === 'object') { + if (typeof mod.pattern === 'string') { + const pattern = mod.pattern; + table.push({ pattern, handlers: mod.default }); + } else { + const pattern = toPattern(relPath); + table.push({ pattern, handlers: mod.default }); + } + } + } + return table; }; -const summarizeProfile = (state) => ({ - id: state.profile.id, - displayName: state.computed.displayName, - email: state.profile.email, -}); - -const listProfiles = async (searchParams) => { - const files = await readDirectoryItems(); - const nameQuery = (searchParams.get('name') || '').trim().toLowerCase(); - const emailQuery = (searchParams.get('email') || '').trim().toLowerCase(); - const items = []; - - for (const fileName of files) { - const username = fileName.slice(0, -5); - const source = await readProfile(username); - const state = buildProfileState(source); - const summary = summarizeProfile(state); - const haystackName = - `${summary.displayName} ${state.profile.firstName} ${state.profile.lastName}`.toLowerCase(); - const haystackEmail = summary.email.toLowerCase(); - const nameMatches = !nameQuery || haystackName.includes(nameQuery); - const emailMatches = !emailQuery || haystackEmail.includes(emailQuery); - if (nameMatches && emailMatches) items.push(summary); +const serveStatic = async (res, pathname) => { + const isShared = pathname.startsWith('/shared/'); + const base = isShared ? SHARED_DIR : STATIC_DIR; + const rel = isShared ? pathname.slice('/shared'.length) : pathname; + const target = safeJoin(base, rel); + if (!target) { + res.writeHead(404, TEXT_HEADERS); + res.end('Not found'); + return; } - - items.sort((a, b) => a.id.localeCompare(b.id)); - if (!nameQuery && !emailQuery) return items.slice(0, 10); - return items; + await serveFile(res, target); }; -const saveProfileState = async (username, incoming, createOnly = false) => { - const filePath = usernameToPath(username); - if (!filePath) { - return { - ok: false, - status: 422, - payload: { ok: false, errors: { id: 'Invalid username' } }, - }; +const createDispatch = (routeTable) => async (req, res) => { + if (!req.url) { + res.writeHead(400, TEXT_HEADERS); + res.end('Bad request'); + return; } - const payload = incoming || {}; - const merged = { - ...payload, - id: username, - }; - const state = buildProfileState(merged); + const url = new URL(req.url, `http://${HOST}:${PORT}`); + const pathname = url.pathname; + const method = req.method || 'GET'; - if (!state.valid) { - return { - ok: false, - status: 422, - payload: { - ok: false, - profile: state.profile, - computed: state.computed, - errors: state.errors, - valid: false, - }, - }; + if (method === 'GET' && (pathname === '/' || pathname === '/index.html')) { + await serveIndex(res, STATIC_DIR); + return; } - if (createOnly) { - try { - await stat(filePath); - return { - ok: false, - status: 409, - payload: { ok: false, error: 'Profile already exists' }, - }; - } catch (error) { - if (!(error && error.code === 'ENOENT')) throw error; + for (const { pattern, handlers } of routeTable) { + const params = matchPattern(pattern, pathname); + if (params === null) continue; + const handler = handlers[method]; + if (!handler) { + res.writeHead(405, TEXT_HEADERS); + res.end('Method not allowed'); + return; } + await handler(req, res, params, ctx); + return; } - await writeFile(filePath, JSON.stringify(state.profile, null, 2), 'utf8'); - return { - ok: true, - status: 200, - payload: { - ok: true, - profile: state.profile, - computed: state.computed, - errors: state.errors, - valid: true, - }, - }; + await serveStatic(res, url.pathname); }; -const serveFile = async (res, filePath) => { - try { - const data = await readFile(filePath); - const ext = path.extname(filePath); - const contentType = MIME_TYPES[ext] || 'application/octet-stream'; - res.writeHead(200, { 'Content-Type': contentType }); - res.end(data); - } catch (error) { - if (error && error.code === 'ENOENT') { - sendText(res, 404, 'Not found'); - return; - } - throw error; - } -}; +const routeTable = await loadRoutes(); const server = createServer(async (req, res) => { try { - if (!req.url) return sendText(res, 400, 'Bad request'); - const url = new URL(req.url, `http://${HOST}:${PORT}`); - const pathname = url.pathname; - const method = req.method || 'GET'; - - if (pathname === '/profile' && method === 'GET') { - const items = await listProfiles(url.searchParams); - return sendJson(res, 200, { ok: true, items }); - } - if (pathname === '/profile') { - return sendText(res, 405, 'Method not allowed'); - } - - if (pathname === '/profiles' && method === 'POST') { - let body; - try { - body = await parseJsonBody(req); - } catch (error) { - if (error.message === 'INVALID_JSON') { - return sendText(res, 400, 'Invalid JSON'); - } - throw error; - } - const requestedId = typeof body.id === 'string' ? body.id.trim() : ''; - if (!USERNAME_RE.test(requestedId)) { - return sendJson(res, 422, { - ok: false, - errors: { id: 'Invalid username' }, - }); - } - const result = await saveProfileState(requestedId, body, true); - return sendJson(res, result.status, result.payload); - } - if (pathname === '/profiles') { - return sendText(res, 405, 'Method not allowed'); - } - - if (pathname.startsWith('/profile/')) { - const username = pathname.slice('/profile/'.length); - if (!USERNAME_RE.test(username)) { - return sendText(res, 404, 'Not found'); - } - - if (method === 'GET') { - const accept = req.headers.accept || ''; - if ( - accept.includes('text/html') && - !accept.includes('application/json') - ) { - return serveFile(res, path.join(STATIC_DIR, 'index.html')); - } - try { - const source = await readProfile(username); - const state = buildProfileState(source); - return sendJson(res, 200, { - ok: true, - profile: state.profile, - computed: state.computed, - errors: state.errors, - valid: state.valid, - }); - } catch (error) { - if (error && error.code === 'ENOENT') { - return sendText(res, 404, 'Not found'); - } - if (error instanceof SyntaxError) { - return sendText(res, 500, 'Corrupt profile JSON'); - } - throw error; - } - } - - if (method === 'POST' || method === 'PUT') { - let body; - try { - body = await parseJsonBody(req); - } catch (error) { - if (error.message === 'INVALID_JSON') { - return sendText(res, 400, 'Invalid JSON'); - } - throw error; - } - const result = await saveProfileState(username, body, false); - return sendJson(res, result.status, result.payload); - } - - if (method === 'DELETE') { - const target = usernameToPath(username); - if (!target) return sendText(res, 404, 'Not found'); - try { - await unlink(target); - return sendJson(res, 200, { ok: true }); - } catch (error) { - if (error && error.code === 'ENOENT') { - return sendText(res, 404, 'Not found'); - } - throw error; - } - } - - return sendText(res, 405, 'Method not allowed'); - } - - if (pathname === '/' || pathname === '/index.html') { - return serveFile(res, path.join(STATIC_DIR, 'index.html')); - } - - if (pathname.startsWith('/shared/')) { - const rel = pathname.slice('/shared/'.length); - const target = safeJoin(SHARED_DIR, rel); - if (!target) return sendText(res, 404, 'Not found'); - return serveFile(res, target); - } - - if (pathname.startsWith('/')) { - const rel = pathname.slice(1); - const target = safeJoin(STATIC_DIR, rel); - if (!target) return sendText(res, 404, 'Not found'); - return serveFile(res, target); - } - - return sendText(res, 404, 'Not found'); + await createDispatch(routeTable)(req, res); } catch (error) { console.error(error); - return sendJson(res, 500, { ok: false, error: 'Unexpected server error' }); + sendJson(res, 500, { ok: false, error: 'Unexpected server error' }); } }); diff --git a/shared/profile-domain.mjs b/shared/profile-domain.mjs index 5c3996f..2faae0e 100644 --- a/shared/profile-domain.mjs +++ b/shared/profile-domain.mjs @@ -21,18 +21,19 @@ const normalizeSkills = (value) => { return value.map(toString).filter((item) => item.length > 0); }; -const PROFILE_FIELDS_METADATA = { +const profileFields = { id: { type: 'string', + label: 'Username', pattern: /^[a-z0-9][a-z0-9-]{1,63}$/i, message: 'Username contains unsupported characters', }, firstName: { type: 'string' }, lastName: { type: 'string' }, - email: { type: 'string' }, + email: { type: 'string', inputType: 'email' }, country: { type: 'string' }, city: { type: 'string' }, - birthDate: { type: 'string' }, + birthDate: { type: 'string', inputType: 'date' }, experienceYears: { type: 'integer', min: 0, @@ -40,7 +41,10 @@ const PROFILE_FIELDS_METADATA = { message: 'Experience years must be an integer between 0 and 60', }, primarySkill: { type: 'string' }, - secondarySkills: { type: 'array' }, + secondarySkills: { + type: 'array', + label: 'Secondary Skills (comma separated)', + }, weeklyAvailabilityHours: { type: 'integer', min: 0, @@ -54,7 +58,14 @@ const PROFILE_FIELDS_METADATA = { message: 'Hourly rate must be between 0 and 1000', }, currency: { type: 'string' }, - bio: { type: 'string' }, + bio: { type: 'string', multiline: true }, + displayName: { type: 'string', computed: true }, + age: { type: 'integer', computed: true }, + seniorityLevel: { type: 'string', computed: true }, + monthlyCapacityHours: { type: 'integer', computed: true }, + estimatedMonthlyIncome: { type: 'number', computed: true }, + profileCompleteness: { type: 'integer', computed: true }, + publicSlug: { type: 'string', computed: true }, }; const normalizeByMetadata = (key, metadata, value) => { @@ -128,11 +139,15 @@ const seniorityFromExperience = (years) => { }; const completeness = (profile) => { - const profileFieldKeys = Object.keys(PROFILE_FIELDS_METADATA); + const profileFieldKeys = Object.keys(profileFields).filter( + (key) => !profileFields[key].computed, + ); const filled = profileFieldKeys.reduce((acc, key) => { const value = profile[key]; if (Array.isArray(value)) return acc + (value.length > 0 ? 1 : 0); - if (typeof value === 'number') { return acc + (Number.isFinite(value) ? 1 : 0); } + if (typeof value === 'number') { + return acc + (Number.isFinite(value) ? 1 : 0); + } return acc + (toString(value).length > 0 ? 1 : 0); }, 0); return Math.round((filled / profileFieldKeys.length) * 100); @@ -141,7 +156,8 @@ const completeness = (profile) => { const normalizeProfile = (profile) => { const source = profile && typeof profile === 'object' ? profile : {}; const normalized = {}; - for (const [key, metadata] of Object.entries(PROFILE_FIELDS_METADATA)) { + for (const [key, metadata] of Object.entries(profileFields)) { + if (metadata.computed) continue; normalized[key] = normalizeByMetadata(key, metadata, source[key]); } return normalized; @@ -152,16 +168,17 @@ const validateProfile = (profile, now = new Date()) => { const normalized = normalizeProfile(profile); const today = isValidDate(now) ? new Date(now.valueOf()) : new Date(); const birthDate = parseDate(normalized.birthDate); - const hasIdPattern = Boolean(PROFILE_FIELDS_METADATA.id.pattern); + const hasIdPattern = Boolean(profileFields.id.pattern); const hasInvalidIdPattern = normalized.id && hasIdPattern && - !PROFILE_FIELDS_METADATA.id.pattern.test(normalized.id); + !profileFields.id.pattern.test(normalized.id); const hasEmptySecondarySkill = normalized.secondarySkills.some( (item) => toString(item).length === 0, ); - for (const [key, metadata] of Object.entries(PROFILE_FIELDS_METADATA)) { + for (const [key, metadata] of Object.entries(profileFields)) { + if (metadata.computed) continue; if (!matchesExpectedType(normalized[key], metadata.type)) { errors[key] = `Expected ${metadata.type} value`; } @@ -169,13 +186,14 @@ const validateProfile = (profile, now = new Date()) => { if (!normalized.id) errors.id = 'Username is required'; if (hasInvalidIdPattern) { - errors.id = PROFILE_FIELDS_METADATA.id.message; + errors.id = profileFields.id.message; } if (!normalized.firstName) errors.firstName = 'First name is required'; if (!normalized.lastName) errors.lastName = 'Last name is required'; if (!normalized.email.includes('@')) errors.email = 'Invalid email'; - for (const [key, metadata] of Object.entries(PROFILE_FIELDS_METADATA)) { + for (const [key, metadata] of Object.entries(profileFields)) { + if (metadata.computed) continue; const fieldError = validateField(normalized[key], metadata); if (fieldError) errors[key] = fieldError; } @@ -216,16 +234,12 @@ const buildProfileState = (profile, now = new Date()) => { const normalized = normalizeProfile(profile); const errors = validateProfile(normalized, now); const computed = calculateProfile(normalized, now); - return { - profile: normalized, - computed, - errors, - valid: Object.keys(errors).length === 0, - }; + const valid = Object.keys(errors).length === 0; + return { profile: normalized, computed, errors, valid }; }; export { - PROFILE_FIELDS_METADATA, + profileFields, normalizeProfile, validateProfile, calculateProfile, diff --git a/static/api.mjs b/static/api.mjs new file mode 100644 index 0000000..031dd50 --- /dev/null +++ b/static/api.mjs @@ -0,0 +1,74 @@ +const HEADERS = { 'Content-Type': 'application/json' }; + +const profileUrl = (username) => `/profile/${encodeURIComponent(username)}`; + +const fetchJson = async (url, options = {}) => { + const res = await fetch(url, options); + const body = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, body }; +}; + +const profileState = (body) => ({ + ok: true, + profile: body.profile, + computed: body.computed, + errors: body.errors, + valid: body.valid, +}); + +const getProfile = async (username) => { + const options = { headers: { Accept: 'application/json' } }; + const { ok, body } = await fetchJson(profileUrl(username), options); + if (!ok || !body?.ok) return { ok: false, error: 'Profile not found' }; + return profileState(body); +}; + +const saveProfile = async (username, data) => { + const json = JSON.stringify(data); + const options = { method: 'POST', headers: HEADERS, body: json }; + const { ok, body } = await fetchJson(profileUrl(username), options); + if (!ok || !body?.ok) { + return { ok: false, errors: body?.errors || { general: 'Save failed' } }; + } + return profileState(body); +}; + +const deleteProfile = async (username) => { + const options = { method: 'DELETE' }; + const { ok } = await fetchJson(profileUrl(username), options); + return { ok }; +}; + +const searchProfiles = async ({ name = '', email = '' } = {}) => { + const params = new URLSearchParams(); + if (name) params.set('name', name); + if (email) params.set('email', email); + const query = params.toString(); + const url = query ? `/profile?${query}` : '/profile'; + const { ok, body } = await fetchJson(url); + if (!ok || !body?.ok) return { ok: false, items: [] }; + return { ok: true, items: body.items || [] }; +}; + +const createProfile = async (data) => { + const json = JSON.stringify(data); + const options = { method: 'POST', headers: HEADERS, body: json }; + const { ok, status, body } = await fetchJson('/profiles', options); + if (!ok || !body?.ok) { + const validationErrors = body?.errors; + const idError = body?.error + ? { id: body.error } + : { general: 'Create failed' }; + const errors = validationErrors || idError; + return { ok: false, status, errors }; + } + return { ok: true, profile: body.profile, computed: body.computed }; +}; + +export { + getProfile, + saveProfile, + deleteProfile, + searchProfiles, + createProfile, +}; diff --git a/static/app.mjs b/static/app.mjs index 10da059..e3ff10b 100644 --- a/static/app.mjs +++ b/static/app.mjs @@ -7,4 +7,3 @@ import './components/profile-directory.mjs'; import './components/profile-search.mjs'; import './components/profile-list.mjs'; import './components/profile-item.mjs'; -import './components/profile-create-dialog.mjs'; diff --git a/static/components/profile-app.html b/static/components/profile-app.html new file mode 100644 index 0000000..81bde5e --- /dev/null +++ b/static/components/profile-app.html @@ -0,0 +1,21 @@ + diff --git a/static/components/profile-app.mjs b/static/components/profile-app.mjs index 47449a7..868a949 100644 --- a/static/components/profile-app.mjs +++ b/static/components/profile-app.mjs @@ -1,45 +1,14 @@ -const template = document.createElement('template'); -template.innerHTML = ` - -
- Professional Profiles - Directory -
-
-`; +import { getProfile } from '/api.mjs'; +import { buildProfileState } from '/shared/profile-domain.mjs'; + +const template = document.getElementById('profile-app'); class ProfileApp extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.main = this.shadowRoot.getElementById('main'); this.homeLink = this.shadowRoot.getElementById('homeLink'); } @@ -47,46 +16,36 @@ class ProfileApp extends HTMLElement { connectedCallback() { this.homeLink.addEventListener('click', (event) => { event.preventDefault(); - this.go('/'); + window.navigation.navigate('/'); }); this.shadowRoot.addEventListener('navigate-profile', (event) => { - this.go(event.detail.path); + window.navigation.navigate(event.detail.path); }); - if ('navigation' in window && window.navigation) { - window.navigation.addEventListener('navigate', (event) => { - const url = new URL(event.destination.url); - if (url.origin !== window.location.origin) return; - event.intercept({ - handler: async () => { - this.renderRoute(url.pathname); - }, - }); + window.navigation.addEventListener('navigate', (event) => { + const url = new URL(event.destination.url); + if (url.origin !== window.location.origin) return; + event.intercept({ + handler: async () => { + this.renderRoute(url.pathname); + }, }); - } + }); - window.addEventListener('popstate', () => - this.renderRoute(window.location.pathname), - ); this.renderRoute(window.location.pathname); } - go(path) { - if ('navigation' in window && window.navigation?.navigate) { - window.navigation.navigate(path); - return; - } - window.history.pushState({}, '', path); - this.renderRoute(path); - } - async renderRoute(pathname) { - while (this.main.firstChild) this.main.removeChild(this.main.firstChild); + this.main.replaceChildren(); if (pathname === '/') { - const directory = document.createElement('profile-directory'); - this.main.append(directory); + this.main.append(document.createElement('profile-directory')); + return; + } + + if (pathname === '/new') { + this.renderCreate(); return; } @@ -96,26 +55,34 @@ class ProfileApp extends HTMLElement { return; } - const message = document.createElement('div'); - message.className = 'error'; - message.textContent = 'Route not found'; - this.main.append(message); + const error = document.createElement('div'); + error.className = 'error'; + error.textContent = 'Route not found'; + this.main.append(error); } - async renderProfile(username) { - const res = await fetch(`/profile/${encodeURIComponent(username)}`, { - headers: { Accept: 'application/json' }, + renderCreate() { + const form = document.createElement('profile-form'); + form.setAttribute('mode', 'create'); + form.editableId = true; + form.state = buildProfileState({}); + form.addEventListener('profile-created', (event) => { + window.navigation.navigate(`/profile/${event.detail.id}`); }); - const body = await res.json().catch(() => null); - if (!res.ok || !body || !body.ok) { - const message = document.createElement('div'); - message.className = 'error'; - message.textContent = 'Profile not found'; - this.main.append(message); + this.main.replaceChildren(form); + } + + async renderProfile(username) { + const result = await getProfile(username); + if (!result.ok) { + const error = document.createElement('div'); + error.className = 'error'; + error.textContent = 'Profile not found'; + this.main.append(error); return; } const form = document.createElement('profile-form'); - form.state = body; + form.state = result; this.main.append(form); } } diff --git a/static/components/profile-create-dialog.html b/static/components/profile-create-dialog.html new file mode 100644 index 0000000..c46eb10 --- /dev/null +++ b/static/components/profile-create-dialog.html @@ -0,0 +1,14 @@ + diff --git a/static/components/profile-create-dialog.mjs b/static/components/profile-create-dialog.mjs index 2213638..9796648 100644 --- a/static/components/profile-create-dialog.mjs +++ b/static/components/profile-create-dialog.mjs @@ -1,36 +1,14 @@ import { buildProfileState } from '/shared/profile-domain.mjs'; +import { createProfile } from '/api.mjs'; -const template = document.createElement('template'); -template.innerHTML = ` - - -

Create profile

- -
- - -
-
-`; +const template = document.getElementById('profile-create-dialog'); class ProfileCreateDialog extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.dialog = this.shadowRoot.getElementById('dialog'); this.form = this.shadowRoot.getElementById('form'); this.cancelBtn = this.shadowRoot.getElementById('cancel'); @@ -62,23 +40,14 @@ class ProfileCreateDialog extends HTMLElement { async submit() { if (!this.state.valid) return; - const res = await fetch('/profiles', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this.state.profile), - }); - const body = await res.json().catch(() => ({})); - if (!res.ok || !body.ok) { - if (body && body.errors) { - this.form.serverErrors = body.errors; - } else if (body && body.error) { - this.form.serverErrors = { id: body.error }; - } + const result = await createProfile(this.state.profile); + if (!result.ok) { + this.form.serverErrors = result.errors; return; } this.dispatchEvent( new CustomEvent('profile-created', { - detail: { id: body.profile.id }, + detail: { id: result.profile.id }, bubbles: true, composed: true, }), diff --git a/static/components/profile-directory.html b/static/components/profile-directory.html new file mode 100644 index 0000000..638df4c --- /dev/null +++ b/static/components/profile-directory.html @@ -0,0 +1,17 @@ + diff --git a/static/components/profile-directory.mjs b/static/components/profile-directory.mjs index 331ed3b..92c7c66 100644 --- a/static/components/profile-directory.mjs +++ b/static/components/profile-directory.mjs @@ -1,45 +1,16 @@ -const template = document.createElement('template'); -template.innerHTML = ` - -
-

Profile Directory

- -
-
- - -
- -`; +import { searchProfiles, deleteProfile } from '/api.mjs'; + +const template = document.getElementById('profile-directory'); class ProfileDirectory extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.search = this.shadowRoot.getElementById('search'); this.list = this.shadowRoot.getElementById('list'); this.createBtn = this.shadowRoot.getElementById('create'); - this.createDialog = this.shadowRoot.getElementById('createDialog'); this.query = { name: '', email: '' }; } @@ -58,15 +29,13 @@ class ProfileDirectory extends HTMLElement { }), ); }); - this.list.addEventListener('delete-profile', (event) => - this.removeProfile(event.detail.id), - ); - this.createBtn.addEventListener('click', () => this.createDialog.open()); - this.createDialog.addEventListener('profile-created', (event) => { - this.load(); + this.list.addEventListener('delete-profile', (event) => { + this.removeProfile(event.detail.id); + }); + this.createBtn.addEventListener('click', () => { this.dispatchEvent( new CustomEvent('navigate-profile', { - detail: { path: `/profile/${event.detail.id}` }, + detail: { path: '/new' }, bubbles: true, composed: true, }), @@ -75,23 +44,14 @@ class ProfileDirectory extends HTMLElement { } async load() { - const params = new URLSearchParams(); - if (this.query.name) params.set('name', this.query.name); - if (this.query.email) params.set('email', this.query.email); - const query = params.toString(); - const url = query ? `/profile?${query}` : '/profile'; - const res = await fetch(url); - const body = await res.json().catch(() => ({ ok: false, items: [] })); - this.list.items = body.items || []; + const { items } = await searchProfiles(this.query); + this.list.items = items; } async removeProfile(id) { - const ok = window.confirm(`Delete profile "${id}"?`); - if (!ok) return; - const res = await fetch(`/profile/${encodeURIComponent(id)}`, { - method: 'DELETE', - }); - if (res.ok) this.load(); + if (!window.confirm(`Delete profile "${id}"?`)) return; + const { ok } = await deleteProfile(id); + if (ok) this.load(); } } diff --git a/static/components/profile-field.html b/static/components/profile-field.html new file mode 100644 index 0000000..183df6f --- /dev/null +++ b/static/components/profile-field.html @@ -0,0 +1,36 @@ + diff --git a/static/components/profile-field.mjs b/static/components/profile-field.mjs index ed9467b..4ee3efa 100644 --- a/static/components/profile-field.mjs +++ b/static/components/profile-field.mjs @@ -1,45 +1,26 @@ -const template = document.createElement('template'); -template.innerHTML = ` - - - - -`; +const template = document.getElementById('profile-field'); class ProfileField extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.labelEl = this.shadowRoot.getElementById('label'); - this.control = this.shadowRoot.getElementById('control'); - this.error = this.shadowRoot.getElementById('error'); - this.control.addEventListener('input', () => { - this.dispatchEvent( - new CustomEvent('field-change', { - detail: { name: this.name, value: this.value }, - bubbles: true, - composed: true, - }), - ); - }); + this.inputEl = this.shadowRoot.getElementById('input'); + this.textareaEl = this.shadowRoot.getElementById('textarea'); + this.errorEl = this.shadowRoot.getElementById('error'); + + const emit = () => { + const event = new CustomEvent('field-change', { + detail: { name: this.fieldName, value: this.value }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); + }; + this.inputEl.addEventListener('input', emit); + this.textareaEl.addEventListener('input', emit); } static get observedAttributes() { @@ -54,62 +35,33 @@ class ProfileField extends HTMLElement { this.render(); } - get name() { + get fieldName() { return this.getAttribute('name') || ''; } get value() { - return this.control.value; + return this.hasAttribute('multiline') + ? this.textareaEl.value + : this.inputEl.value; } render() { const multiline = this.hasAttribute('multiline'); const disabled = this.hasAttribute('disabled'); - const controlId = `field-${this.name}`; + const id = `field-${this.fieldName}`; - if (multiline && this.control.tagName !== 'TEXTAREA') { - const textarea = document.createElement('textarea'); - textarea.id = 'control'; - textarea.rows = 3; - this.control.replaceWith(textarea); - this.control = textarea; - this.control.addEventListener('input', () => { - this.dispatchEvent( - new CustomEvent('field-change', { - detail: { name: this.name, value: this.value }, - bubbles: true, - composed: true, - }), - ); - }); - } - - if (!multiline && this.control.tagName !== 'INPUT') { - const input = document.createElement('input'); - input.id = 'control'; - this.control.replaceWith(input); - this.control = input; - this.control.addEventListener('input', () => { - this.dispatchEvent( - new CustomEvent('field-change', { - detail: { name: this.name, value: this.value }, - bubbles: true, - composed: true, - }), - ); - }); - } + this.inputEl.hidden = multiline; + this.textareaEl.hidden = !multiline; - this.control.id = controlId; - this.labelEl.setAttribute('for', controlId); - this.labelEl.textContent = this.getAttribute('label') || this.name; + const active = multiline ? this.textareaEl : this.inputEl; + this.labelEl.setAttribute('for', id); + active.id = id; + this.labelEl.textContent = this.getAttribute('label') || this.fieldName; - if (this.control.tagName === 'INPUT') { - this.control.type = this.getAttribute('type') || 'text'; - } - this.control.value = this.getAttribute('value') || ''; - this.control.disabled = disabled; - this.error.setAttribute('message', this.getAttribute('error') || ''); + if (!multiline) this.inputEl.type = this.getAttribute('type') || 'text'; + active.value = this.getAttribute('value') || ''; + active.disabled = disabled; + this.errorEl.setAttribute('message', this.getAttribute('error') || ''); } } diff --git a/static/components/profile-form.html b/static/components/profile-form.html new file mode 100644 index 0000000..94736c2 --- /dev/null +++ b/static/components/profile-form.html @@ -0,0 +1,48 @@ + diff --git a/static/components/profile-form.mjs b/static/components/profile-form.mjs index 141aec1..2172f9b 100644 --- a/static/components/profile-form.mjs +++ b/static/components/profile-form.mjs @@ -1,78 +1,16 @@ -import { - PROFILE_FIELDS_METADATA, - buildProfileState, -} from '/shared/profile-domain.mjs'; - -const FIELD_LABEL_OVERRIDES = { - id: 'Username', - secondarySkills: 'Secondary Skills (comma separated)', -}; - -const FIELD_TYPE_OVERRIDES = { - birthDate: 'date', - email: 'email', -}; - -const toFieldLabel = (name) => { - const words = name - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/_/g, ' ') - .trim(); - return words.charAt(0).toUpperCase() + words.slice(1); -}; - -const toInputType = (name, metadata) => { - if (FIELD_TYPE_OVERRIDES[name]) return FIELD_TYPE_OVERRIDES[name]; - if (metadata.type === 'number' || metadata.type === 'integer') return 'number'; - return 'text'; -}; - -const FIELDS = Object.entries(PROFILE_FIELDS_METADATA).map(([name, metadata]) => ({ - name, - label: FIELD_LABEL_OVERRIDES[name] || toFieldLabel(name), - type: toInputType(name, metadata), - metadata, -})); - -const template = document.createElement('template'); -template.innerHTML = ` - -
-

Profile

-
- -
- - -
-
-`; +import { profileFields, buildProfileState } from '/shared/profile-domain.mjs'; +import { saveProfile, createProfile } from '/api.mjs'; + +const template = document.getElementById('profile-form'); class ProfileForm extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.formEl = this.shadowRoot.getElementById('form'); + this.titleEl = this.shadowRoot.getElementById('title'); this.fieldsEl = this.shadowRoot.getElementById('fields'); this.summaryEl = this.shadowRoot.getElementById('summary'); this.saveBtn = this.shadowRoot.getElementById('save'); @@ -115,17 +53,32 @@ class ProfileForm extends HTMLElement { this.render(); } + get isCreate() { + return this.getAttribute('mode') === 'create'; + } + renderFields() { - while (this.fieldsEl.firstChild) { this.fieldsEl.removeChild(this.fieldsEl.firstChild); } - for (const { name, label, type } of FIELDS) { + const nodes = []; + for (const [name, metadata] of Object.entries(profileFields)) { + if (metadata.computed) continue; const field = document.createElement('profile-field'); field.setAttribute('name', name); + let label = name; + if (metadata.label) label = metadata.label; field.setAttribute('label', label); + + let type = 'text'; + if (metadata.inputType) { + type = metadata.inputType; + } else if (metadata.type === 'number' || metadata.type === 'integer') { + type = 'number'; + } field.setAttribute('type', type); - if (name === 'bio') field.setAttribute('multiline', ''); - this.fieldsEl.append(field); + if (metadata.multiline) field.setAttribute('multiline', ''); this.fieldEls.set(name, field); + nodes.push(field); } + this.fieldsEl.replaceChildren(...nodes); } updateField(name, value) { @@ -133,11 +86,11 @@ class ProfileForm extends HTMLElement { if (name === 'secondarySkills') { next[name] = value .split(',') - .map((item) => item.trim()) + .map((s) => s.trim()) .filter(Boolean); } else if ( - PROFILE_FIELDS_METADATA[name]?.type === 'number' || - PROFILE_FIELDS_METADATA[name]?.type === 'integer' + profileFields[name]?.type === 'number' || + profileFields[name]?.type === 'integer' ) { next[name] = value === '' ? 0 : Number(value); } else { @@ -157,22 +110,32 @@ class ProfileForm extends HTMLElement { } async handleSave() { - if (this.getAttribute('mode') === 'create') return; if (!this.state.valid) return; const username = this.state.profile.id; - const response = await fetch(`/profile/${encodeURIComponent(username)}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(this.state.profile), - }); - const body = await response.json().catch(() => ({})); - if (!response.ok || !body.ok) { - this.serverErrors = body.errors || { general: 'Save failed' }; + if (this.isCreate) { + const result = await createProfile(this.state.profile); + if (!result.ok) { + this.serverErrors = result.errors; + return; + } + this.dispatchEvent( + new CustomEvent('profile-created', { + detail: { id: result.profile.id }, + bubbles: true, + composed: true, + }), + ); + return; + } + + const result = await saveProfile(username, this.state.profile); + if (!result.ok) { + this.serverErrors = result.errors; this.statusEl.textContent = ''; return; } - this._state = body; + this._state = result; this.statusEl.textContent = 'Saved'; setTimeout(() => { if (this.statusEl.textContent === 'Saved') this.statusEl.textContent = ''; @@ -190,34 +153,36 @@ class ProfileForm extends HTMLElement { render() { if (!this.isConnected) return; const profile = this.state?.profile || {}; - const errors = { - ...this.state?.errors || {}, - ...this._serverErrors || {}, - }; + const errors = { ...this.state?.errors, ...this._serverErrors }; - for (const { name } of FIELDS) { + let title = 'Profile'; + if (this.isCreate) title = 'Create Profile'; + else if (profile.id) title = `Edit: ${profile.id}`; + this.titleEl.textContent = title; + + this.saveBtn.textContent = this.isCreate ? 'Create' : 'Save'; + this.saveBtn.disabled = !this.state.valid; + + for (const [name, metadata] of Object.entries(profileFields)) { + if (metadata.computed) continue; const field = this.fieldEls.get(name); if (!field) continue; + const raw = profile[name]; + let display; if (name === 'secondarySkills') { - field.setAttribute( - 'value', - Array.isArray(profile[name]) ? profile[name].join(', ') : '', - ); + display = Array.isArray(raw) ? raw.join(', ') : ''; } else { - field.setAttribute( - 'value', - profile[name] === undefined || profile[name] === null - ? '' - : String(profile[name]), - ); + display = raw === null || raw === undefined ? '' : String(raw); } + field.setAttribute('value', display); field.setAttribute('error', errors[name] || ''); - if (name === 'id' && !this._editableId) { field.setAttribute('disabled', ''); } - if (name !== 'id' || this._editableId) field.removeAttribute('disabled'); + if (name === 'id' && !this._editableId) { + field.setAttribute('disabled', ''); + } else { + field.removeAttribute('disabled'); + } } - this.saveBtn.disabled = - !this.state.valid || this.getAttribute('mode') === 'create'; this.summaryEl.data = this.state.computed || {}; } } diff --git a/static/components/profile-item.html b/static/components/profile-item.html new file mode 100644 index 0000000..e87e01a --- /dev/null +++ b/static/components/profile-item.html @@ -0,0 +1,24 @@ + diff --git a/static/components/profile-item.mjs b/static/components/profile-item.mjs index 5f9241d..d6f4873 100644 --- a/static/components/profile-item.mjs +++ b/static/components/profile-item.mjs @@ -1,49 +1,11 @@ -const template = document.createElement('template'); -template.innerHTML = ` - -
-
-
- -
-
- - -
-
-`; +const template = document.getElementById('profile-item'); class ProfileItem extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.nameEl = this.shadowRoot.getElementById('name'); this.emailEl = this.shadowRoot.getElementById('email'); this.openBtn = this.shadowRoot.getElementById('open'); @@ -53,22 +15,20 @@ class ProfileItem extends HTMLElement { connectedCallback() { this.render(); this.openBtn.addEventListener('click', () => { - this.dispatchEvent( - new CustomEvent('open-profile', { - detail: { id: this.getAttribute('profile-id') || '' }, - bubbles: true, - composed: true, - }), - ); + const event = new CustomEvent('open-profile', { + detail: { id: this.getAttribute('profile-id') || '' }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); }); this.deleteBtn.addEventListener('click', () => { - this.dispatchEvent( - new CustomEvent('delete-profile', { - detail: { id: this.getAttribute('profile-id') || '' }, - bubbles: true, - composed: true, - }), - ); + const event = new CustomEvent('delete-profile', { + detail: { id: this.getAttribute('profile-id') || '' }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); }); } diff --git a/static/components/profile-list.mjs b/static/components/profile-list.mjs index f4abebf..986ead8 100644 --- a/static/components/profile-list.mjs +++ b/static/components/profile-list.mjs @@ -13,20 +13,21 @@ class ProfileList extends HTMLElement { } render() { - while (this.firstChild) this.removeChild(this.firstChild); - if (this.items.length === 0) { + const items = this.items; + if (items.length === 0) { const empty = document.createElement('p'); empty.textContent = 'No profiles found.'; - this.append(empty); + this.replaceChildren(empty); return; } - for (const item of this.items) { + const nodes = items.map((item) => { const el = document.createElement('profile-item'); el.setAttribute('profile-id', item.id); el.setAttribute('display-name', item.displayName || item.id); el.setAttribute('email', item.email || ''); - this.append(el); - } + return el; + }); + this.replaceChildren(...nodes); } } diff --git a/static/components/profile-search.html b/static/components/profile-search.html new file mode 100644 index 0000000..56dba94 --- /dev/null +++ b/static/components/profile-search.html @@ -0,0 +1,11 @@ + diff --git a/static/components/profile-search.mjs b/static/components/profile-search.mjs index feda424..ba9ac3f 100644 --- a/static/components/profile-search.mjs +++ b/static/components/profile-search.mjs @@ -1,34 +1,11 @@ -const template = document.createElement('template'); -template.innerHTML = ` - -
- - -
-`; +const template = document.getElementById('profile-search'); class ProfileSearch extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.nameInput = this.shadowRoot.getElementById('name'); this.emailInput = this.shadowRoot.getElementById('email'); this.timer = null; @@ -36,18 +13,17 @@ class ProfileSearch extends HTMLElement { connectedCallback() { const emit = () => { - if (this.timer) clearTimeout(this.timer); + clearTimeout(this.timer); this.timer = setTimeout(() => { - this.dispatchEvent( - new CustomEvent('search-change', { - detail: { - name: this.nameInput.value, - email: this.emailInput.value, - }, - bubbles: true, - composed: true, - }), - ); + const event = new CustomEvent('search-change', { + detail: { + name: this.nameInput.value, + email: this.emailInput.value, + }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(event); }, 250); }; this.nameInput.addEventListener('input', emit); diff --git a/static/components/profile-summary.html b/static/components/profile-summary.html new file mode 100644 index 0000000..8493645 --- /dev/null +++ b/static/components/profile-summary.html @@ -0,0 +1,41 @@ + diff --git a/static/components/profile-summary.mjs b/static/components/profile-summary.mjs index e2e7392..db58da6 100644 --- a/static/components/profile-summary.mjs +++ b/static/components/profile-summary.mjs @@ -1,49 +1,13 @@ -const template = document.createElement('template'); -template.innerHTML = ` - -
Display:
-
Age:
-
Seniority:
-
Monthly Capacity:
-
Monthly Income:
-
Completeness:
-
Public Slug:
-`; +import { profileFields } from '/shared/profile-domain.mjs'; + +const template = document.getElementById('profile-summary'); class ProfileSummary extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); - this.ids = {}; - for (const id of [ - 'displayName', - 'age', - 'seniorityLevel', - 'monthlyCapacityHours', - 'estimatedMonthlyIncome', - 'profileCompleteness', - 'publicSlug', - ]) { - this.ids[id] = this.shadowRoot.getElementById(id); - } + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); } set data(value) { @@ -52,39 +16,22 @@ class ProfileSummary extends HTMLElement { } connectedCallback() { - this.upgradeProperty('data'); + if (Object.prototype.hasOwnProperty.call(this, 'data')) { + const value = this.data; + delete this.data; + this.data = value; + } this.render(); } - upgradeProperty(prop) { - if (!Object.prototype.hasOwnProperty.call(this, prop)) return; - const value = this[prop]; - delete this[prop]; - this[prop] = value; - } - render() { const data = this._data || {}; - this.ids.displayName.textContent = data.displayName || '-'; - this.ids.age.textContent = - data.age === null || data.age === undefined ? '-' : String(data.age); - this.ids.seniorityLevel.textContent = data.seniorityLevel || '-'; - this.ids.monthlyCapacityHours.textContent = - data.monthlyCapacityHours === null || - data.monthlyCapacityHours === undefined - ? '-' - : String(data.monthlyCapacityHours); - this.ids.estimatedMonthlyIncome.textContent = - data.estimatedMonthlyIncome === null || - data.estimatedMonthlyIncome === undefined - ? '-' - : String(data.estimatedMonthlyIncome); - this.ids.profileCompleteness.textContent = - data.profileCompleteness === null || - data.profileCompleteness === undefined - ? '-' - : `${data.profileCompleteness}%`; - this.ids.publicSlug.textContent = data.publicSlug || '-'; + for (const [key, metadata] of Object.entries(profileFields)) { + if (!metadata.computed) continue; + const el = this.shadowRoot.getElementById(key); + if (!el) continue; + el.textContent = String(data[key] ?? '-'); + } } } diff --git a/static/components/validation-message.html b/static/components/validation-message.html new file mode 100644 index 0000000..1047bc3 --- /dev/null +++ b/static/components/validation-message.html @@ -0,0 +1,6 @@ + diff --git a/static/components/validation-message.mjs b/static/components/validation-message.mjs index dd26362..ca326f9 100644 --- a/static/components/validation-message.mjs +++ b/static/components/validation-message.mjs @@ -1,22 +1,11 @@ -const template = document.createElement('template'); -template.innerHTML = ` - - -`; +const template = document.getElementById('validation-message'); class ValidationMessage extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.shadowRoot.append(template.content.cloneNode(true)); + const content = template.content.cloneNode(true); + this.shadowRoot.append(content); this.textEl = this.shadowRoot.getElementById('text'); } @@ -33,8 +22,7 @@ class ValidationMessage extends HTMLElement { } render() { - const message = this.getAttribute('message') || ''; - this.textEl.textContent = message; + this.textEl.textContent = this.getAttribute('message') || ''; } } diff --git a/static/index.html b/static/index.html index 8738e24..59864db 100644 --- a/static/index.html +++ b/static/index.html @@ -9,5 +9,6 @@ + diff --git a/static/styles.css b/static/styles.css index 7aadd3c..024b555 100644 --- a/static/styles.css +++ b/static/styles.css @@ -14,3 +14,32 @@ input, textarea { font: inherit; } + +button { + padding: 0.35rem 1rem; + border: 1px solid transparent; + border-radius: 6px; + background: #1e40af; + color: #fff; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +button:hover:not(:disabled) { background: #1d35a0; } +button:disabled { background: #94a3b8; cursor: default; } + +button.secondary { + background: #f1f5f9; + color: #334155; + border-color: #cbd5e1; +} +button.secondary:hover:not(:disabled) { background: #e2e8f0; } + +button.danger { + background: #fee2e2; + color: #991b1b; + border-color: #fca5a5; +} +button.danger:hover:not(:disabled) { background: #fecaca; } From d47feac2ebfc74f98a2b03514a92b6e4eb719dc2 Mon Sep 17 00:00:00 2001 From: David Totrashvili Date: Fri, 12 Jun 2026 13:09:23 +0500 Subject: [PATCH 2/3] Add defineCustomElement helper and refactor components Introduce a defineCustomElement factory that wires up the shadow DOM, template cloning, observed attributes and element references, then refactor the profile components to use it. Co-Authored-By: Claude Opus 4.8 (1M context) --- static/components/profile-app.mjs | 31 ++++------ static/components/profile-create-dialog.mjs | 47 +++++++-------- static/components/profile-directory.mjs | 27 +++------ static/components/profile-field.mjs | 67 ++++++++++++--------- static/components/profile-form.mjs | 57 +++++++++--------- static/components/profile-item.mjs | 32 ++++------ static/components/profile-list.mjs | 7 ++- static/components/profile-search.mjs | 21 +++---- static/components/profile-summary.mjs | 11 +--- static/components/validation-message.mjs | 22 +++---- static/define-custom-element.mjs | 31 ++++++++++ 11 files changed, 176 insertions(+), 177 deletions(-) create mode 100644 static/define-custom-element.mjs diff --git a/static/components/profile-app.mjs b/static/components/profile-app.mjs index 868a949..d4849af 100644 --- a/static/components/profile-app.mjs +++ b/static/components/profile-app.mjs @@ -1,20 +1,10 @@ import { getProfile } from '/api.mjs'; import { buildProfileState } from '/shared/profile-domain.mjs'; - -const template = document.getElementById('profile-app'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileApp extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.main = this.shadowRoot.getElementById('main'); - this.homeLink = this.shadowRoot.getElementById('homeLink'); - } - connectedCallback() { - this.homeLink.addEventListener('click', (event) => { + this.elements.homeLink.addEventListener('click', (event) => { event.preventDefault(); window.navigation.navigate('/'); }); @@ -37,10 +27,10 @@ class ProfileApp extends HTMLElement { } async renderRoute(pathname) { - this.main.replaceChildren(); + this.elements.main.replaceChildren(); if (pathname === '/') { - this.main.append(document.createElement('profile-directory')); + this.elements.main.append(document.createElement('profile-directory')); return; } @@ -58,7 +48,7 @@ class ProfileApp extends HTMLElement { const error = document.createElement('div'); error.className = 'error'; error.textContent = 'Route not found'; - this.main.append(error); + this.elements.main.append(error); } renderCreate() { @@ -69,7 +59,7 @@ class ProfileApp extends HTMLElement { form.addEventListener('profile-created', (event) => { window.navigation.navigate(`/profile/${event.detail.id}`); }); - this.main.replaceChildren(form); + this.elements.main.replaceChildren(form); } async renderProfile(username) { @@ -78,13 +68,16 @@ class ProfileApp extends HTMLElement { const error = document.createElement('div'); error.className = 'error'; error.textContent = 'Profile not found'; - this.main.append(error); + this.elements.main.append(error); return; } const form = document.createElement('profile-form'); form.state = result; - this.main.append(form); + this.elements.main.append(form); } } -customElements.define('profile-app', ProfileApp); +defineCustomElement(ProfileApp, { + name: 'profile-app', + elements: { main: 'main', homeLink: 'homeLink' }, +}); diff --git a/static/components/profile-create-dialog.mjs b/static/components/profile-create-dialog.mjs index 9796648..1394ff8 100644 --- a/static/components/profile-create-dialog.mjs +++ b/static/components/profile-create-dialog.mjs @@ -1,48 +1,37 @@ import { buildProfileState } from '/shared/profile-domain.mjs'; import { createProfile } from '/api.mjs'; - -const template = document.getElementById('profile-create-dialog'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileCreateDialog extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.dialog = this.shadowRoot.getElementById('dialog'); - this.form = this.shadowRoot.getElementById('form'); - this.cancelBtn = this.shadowRoot.getElementById('cancel'); - this.createBtn = this.shadowRoot.getElementById('create'); - this.state = buildProfileState({}); - } + state = buildProfileState({}); connectedCallback() { - this.form.state = this.state; - this.form.editableId = true; - this.form.addEventListener('profile-state-change', (event) => { + this.elements.form.state = this.state; + this.elements.form.editableId = true; + this.elements.form.addEventListener('profile-state-change', (event) => { this.state = event.detail.state; - this.createBtn.disabled = !this.state.valid; + this.elements.createBtn.disabled = !this.state.valid; }); - this.cancelBtn.addEventListener('click', () => this.close()); - this.createBtn.addEventListener('click', () => this.submit()); + this.elements.cancelBtn.addEventListener('click', () => this.close()); + this.elements.createBtn.addEventListener('click', () => this.submit()); } open() { this.state = buildProfileState({}); - this.form.state = this.state; - this.createBtn.disabled = !this.state.valid; - this.dialog.showModal(); + this.elements.form.state = this.state; + this.elements.createBtn.disabled = !this.state.valid; + this.elements.dialog.showModal(); } close() { - this.dialog.close(); + this.elements.dialog.close(); } async submit() { if (!this.state.valid) return; const result = await createProfile(this.state.profile); if (!result.ok) { - this.form.serverErrors = result.errors; + this.elements.form.serverErrors = result.errors; return; } this.dispatchEvent( @@ -56,4 +45,12 @@ class ProfileCreateDialog extends HTMLElement { } } -customElements.define('profile-create-dialog', ProfileCreateDialog); +defineCustomElement(ProfileCreateDialog, { + name: 'profile-create-dialog', + elements: { + dialog: 'dialog', + form: 'form', + cancelBtn: 'cancel', + createBtn: 'create', + }, +}); diff --git a/static/components/profile-directory.mjs b/static/components/profile-directory.mjs index 92c7c66..1a17285 100644 --- a/static/components/profile-directory.mjs +++ b/static/components/profile-directory.mjs @@ -1,26 +1,16 @@ import { searchProfiles, deleteProfile } from '/api.mjs'; - -const template = document.getElementById('profile-directory'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileDirectory extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.search = this.shadowRoot.getElementById('search'); - this.list = this.shadowRoot.getElementById('list'); - this.createBtn = this.shadowRoot.getElementById('create'); - this.query = { name: '', email: '' }; - } + query = { name: '', email: '' }; connectedCallback() { this.load(); - this.search.addEventListener('search-change', (event) => { + this.elements.search.addEventListener('search-change', (event) => { this.query = event.detail; this.load(); }); - this.list.addEventListener('open-profile', (event) => { + this.elements.list.addEventListener('open-profile', (event) => { this.dispatchEvent( new CustomEvent('navigate-profile', { detail: { path: `/profile/${event.detail.id}` }, @@ -32,7 +22,7 @@ class ProfileDirectory extends HTMLElement { this.list.addEventListener('delete-profile', (event) => { this.removeProfile(event.detail.id); }); - this.createBtn.addEventListener('click', () => { + this.elements.createBtn.addEventListener('click', () => { this.dispatchEvent( new CustomEvent('navigate-profile', { detail: { path: '/new' }, @@ -45,7 +35,7 @@ class ProfileDirectory extends HTMLElement { async load() { const { items } = await searchProfiles(this.query); - this.list.items = items; + this.elements.list.items = items; } async removeProfile(id) { @@ -55,4 +45,7 @@ class ProfileDirectory extends HTMLElement { } } -customElements.define('profile-directory', ProfileDirectory); +defineCustomElement(ProfileDirectory, { + name: 'profile-directory', + elements: { search: 'search', list: 'list', createBtn: 'create' }, +}); diff --git a/static/components/profile-field.mjs b/static/components/profile-field.mjs index 4ee3efa..a9f3c95 100644 --- a/static/components/profile-field.mjs +++ b/static/components/profile-field.mjs @@ -1,16 +1,7 @@ -const template = document.getElementById('profile-field'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileField extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.labelEl = this.shadowRoot.getElementById('label'); - this.inputEl = this.shadowRoot.getElementById('input'); - this.textareaEl = this.shadowRoot.getElementById('textarea'); - this.errorEl = this.shadowRoot.getElementById('error'); - + connectedCallback() { const emit = () => { const event = new CustomEvent('field-change', { detail: { name: this.fieldName, value: this.value }, @@ -19,15 +10,8 @@ class ProfileField extends HTMLElement { }); this.dispatchEvent(event); }; - this.inputEl.addEventListener('input', emit); - this.textareaEl.addEventListener('input', emit); - } - - static get observedAttributes() { - return ['name', 'label', 'type', 'value', 'error', 'multiline', 'disabled']; - } - - connectedCallback() { + this.elements.inputEl.addEventListener('input', emit); + this.elements.textareaEl.addEventListener('input', emit); this.render(); } @@ -41,8 +25,8 @@ class ProfileField extends HTMLElement { get value() { return this.hasAttribute('multiline') - ? this.textareaEl.value - : this.inputEl.value; + ? this.elements.textareaEl.value + : this.elements.inputEl.value; } render() { @@ -50,19 +34,42 @@ class ProfileField extends HTMLElement { const disabled = this.hasAttribute('disabled'); const id = `field-${this.fieldName}`; - this.inputEl.hidden = multiline; - this.textareaEl.hidden = !multiline; + this.elements.inputEl.hidden = multiline; + this.elements.textareaEl.hidden = !multiline; - const active = multiline ? this.textareaEl : this.inputEl; - this.labelEl.setAttribute('for', id); + const active = multiline ? this.elements.textareaEl : this.elements.inputEl; + this.elements.labelEl.setAttribute('for', id); active.id = id; - this.labelEl.textContent = this.getAttribute('label') || this.fieldName; + this.elements.labelEl.textContent = + this.getAttribute('label') || this.fieldName; - if (!multiline) this.inputEl.type = this.getAttribute('type') || 'text'; + if (!multiline) { + this.elements.inputEl.type = this.getAttribute('type') || 'text'; + } active.value = this.getAttribute('value') || ''; active.disabled = disabled; - this.errorEl.setAttribute('message', this.getAttribute('error') || ''); + this.elements.errorEl.setAttribute( + 'message', + this.getAttribute('error') || '', + ); } } -customElements.define('profile-field', ProfileField); +defineCustomElement(ProfileField, { + name: 'profile-field', + observedAttributes: [ + 'name', + 'label', + 'type', + 'value', + 'error', + 'multiline', + 'disabled', + ], + elements: { + labelEl: 'label', + inputEl: 'input', + textareaEl: 'textarea', + errorEl: 'error', + }, +}); diff --git a/static/components/profile-form.mjs b/static/components/profile-form.mjs index 2172f9b..88cf57a 100644 --- a/static/components/profile-form.mjs +++ b/static/components/profile-form.mjs @@ -1,33 +1,20 @@ import { profileFields, buildProfileState } from '/shared/profile-domain.mjs'; import { saveProfile, createProfile } from '/api.mjs'; - -const template = document.getElementById('profile-form'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileForm extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.formEl = this.shadowRoot.getElementById('form'); - this.titleEl = this.shadowRoot.getElementById('title'); - this.fieldsEl = this.shadowRoot.getElementById('fields'); - this.summaryEl = this.shadowRoot.getElementById('summary'); - this.saveBtn = this.shadowRoot.getElementById('save'); - this.statusEl = this.shadowRoot.getElementById('status'); - this.fieldEls = new Map(); - this._state = buildProfileState({}); - this._serverErrors = {}; - this._editableId = false; - } + fieldEls = new Map(); + _state = buildProfileState({}); + _serverErrors = {}; + _editableId = false; connectedCallback() { this.renderFields(); - this.formEl.addEventListener('submit', (event) => { + this.elements.formEl.addEventListener('submit', (event) => { event.preventDefault(); this.handleSave(); }); - this.formEl.addEventListener('field-change', (event) => { + this.elements.formEl.addEventListener('field-change', (event) => { this.updateField(event.detail.name, event.detail.value); }); this.render(); @@ -78,7 +65,7 @@ class ProfileForm extends HTMLElement { this.fieldEls.set(name, field); nodes.push(field); } - this.fieldsEl.replaceChildren(...nodes); + this.elements.fieldsEl.replaceChildren(...nodes); } updateField(name, value) { @@ -132,13 +119,15 @@ class ProfileForm extends HTMLElement { const result = await saveProfile(username, this.state.profile); if (!result.ok) { this.serverErrors = result.errors; - this.statusEl.textContent = ''; + this.elements.statusEl.textContent = ''; return; } this._state = result; - this.statusEl.textContent = 'Saved'; + this.elements.statusEl.textContent = 'Saved'; setTimeout(() => { - if (this.statusEl.textContent === 'Saved') this.statusEl.textContent = ''; + if (this.elements.statusEl.textContent === 'Saved') { + this.elements.statusEl.textContent = ''; + } }, 1500); this.dispatchEvent( new CustomEvent('profile-saved', { @@ -158,10 +147,10 @@ class ProfileForm extends HTMLElement { let title = 'Profile'; if (this.isCreate) title = 'Create Profile'; else if (profile.id) title = `Edit: ${profile.id}`; - this.titleEl.textContent = title; + this.elements.titleEl.textContent = title; - this.saveBtn.textContent = this.isCreate ? 'Create' : 'Save'; - this.saveBtn.disabled = !this.state.valid; + this.elements.saveBtn.textContent = this.isCreate ? 'Create' : 'Save'; + this.elements.saveBtn.disabled = !this.state.valid; for (const [name, metadata] of Object.entries(profileFields)) { if (metadata.computed) continue; @@ -183,8 +172,18 @@ class ProfileForm extends HTMLElement { } } - this.summaryEl.data = this.state.computed || {}; + this.elements.summaryEl.data = this.state.computed || {}; } } -customElements.define('profile-form', ProfileForm); +defineCustomElement(ProfileForm, { + name: 'profile-form', + elements: { + formEl: 'form', + titleEl: 'title', + fieldsEl: 'fields', + summaryEl: 'summary', + saveBtn: 'save', + statusEl: 'status', + }, +}); diff --git a/static/components/profile-item.mjs b/static/components/profile-item.mjs index d6f4873..a97fe0a 100644 --- a/static/components/profile-item.mjs +++ b/static/components/profile-item.mjs @@ -1,17 +1,6 @@ -const template = document.getElementById('profile-item'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileItem extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.nameEl = this.shadowRoot.getElementById('name'); - this.emailEl = this.shadowRoot.getElementById('email'); - this.openBtn = this.shadowRoot.getElementById('open'); - this.deleteBtn = this.shadowRoot.getElementById('delete'); - } - connectedCallback() { this.render(); this.openBtn.addEventListener('click', () => { @@ -32,18 +21,23 @@ class ProfileItem extends HTMLElement { }); } - static get observedAttributes() { - return ['display-name', 'email']; - } - attributeChangedCallback() { this.render(); } render() { - this.nameEl.textContent = this.getAttribute('display-name') || ''; - this.emailEl.textContent = this.getAttribute('email') || ''; + this.elements.nameEl.textContent = this.getAttribute('display-name') || ''; + this.elements.emailEl.textContent = this.getAttribute('email') || ''; } } -customElements.define('profile-item', ProfileItem); +defineCustomElement(ProfileItem, { + name: 'profile-item', + observedAttributes: ['display-name', 'email'], + elements: { + nameEl: 'name', + emailEl: 'email', + openBtn: 'open', + deleteBtn: 'delete', + }, +}); diff --git a/static/components/profile-list.mjs b/static/components/profile-list.mjs index 986ead8..abaab69 100644 --- a/static/components/profile-list.mjs +++ b/static/components/profile-list.mjs @@ -1,3 +1,5 @@ +import { defineCustomElement } from '/define-custom-element.mjs'; + class ProfileList extends HTMLElement { set items(value) { this._items = Array.isArray(value) ? value : []; @@ -31,4 +33,7 @@ class ProfileList extends HTMLElement { } } -customElements.define('profile-list', ProfileList); +defineCustomElement(ProfileList, { + name: 'profile-list', + shadow: false, +}); diff --git a/static/components/profile-search.mjs b/static/components/profile-search.mjs index ba9ac3f..a05342c 100644 --- a/static/components/profile-search.mjs +++ b/static/components/profile-search.mjs @@ -1,15 +1,7 @@ -const template = document.getElementById('profile-search'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileSearch extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.nameInput = this.shadowRoot.getElementById('name'); - this.emailInput = this.shadowRoot.getElementById('email'); - this.timer = null; - } + timer = null; connectedCallback() { const emit = () => { @@ -26,9 +18,12 @@ class ProfileSearch extends HTMLElement { this.dispatchEvent(event); }, 250); }; - this.nameInput.addEventListener('input', emit); - this.emailInput.addEventListener('input', emit); + this.elements.nameInput.addEventListener('input', emit); + this.elements.emailInput.addEventListener('input', emit); } } -customElements.define('profile-search', ProfileSearch); +defineCustomElement(ProfileSearch, { + name: 'profile-search', + elements: { nameInput: 'name', emailInput: 'email' }, +}); diff --git a/static/components/profile-summary.mjs b/static/components/profile-summary.mjs index db58da6..19dc52e 100644 --- a/static/components/profile-summary.mjs +++ b/static/components/profile-summary.mjs @@ -1,15 +1,8 @@ import { profileFields } from '/shared/profile-domain.mjs'; -const template = document.getElementById('profile-summary'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileSummary extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - } - set data(value) { this._data = value || {}; this.render(); @@ -35,4 +28,4 @@ class ProfileSummary extends HTMLElement { } } -customElements.define('profile-summary', ProfileSummary); +defineCustomElement(ProfileSummary, { name: 'profile-summary' }); diff --git a/static/components/validation-message.mjs b/static/components/validation-message.mjs index ca326f9..80dd176 100644 --- a/static/components/validation-message.mjs +++ b/static/components/validation-message.mjs @@ -1,18 +1,6 @@ -const template = document.getElementById('validation-message'); +import { defineCustomElement } from '/define-custom-element.mjs'; class ValidationMessage extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - const content = template.content.cloneNode(true); - this.shadowRoot.append(content); - this.textEl = this.shadowRoot.getElementById('text'); - } - - static get observedAttributes() { - return ['message']; - } - attributeChangedCallback() { this.render(); } @@ -22,8 +10,12 @@ class ValidationMessage extends HTMLElement { } render() { - this.textEl.textContent = this.getAttribute('message') || ''; + this.elements.textEl.textContent = this.getAttribute('message') || ''; } } -customElements.define('validation-message', ValidationMessage); +defineCustomElement(ValidationMessage, { + name: 'validation-message', + observedAttributes: ['message'], + elements: { textEl: 'text' }, +}); diff --git a/static/define-custom-element.mjs b/static/define-custom-element.mjs new file mode 100644 index 0000000..6e6bf66 --- /dev/null +++ b/static/define-custom-element.mjs @@ -0,0 +1,31 @@ +export const defineCustomElement = (ComponentClass, metadata) => { + const { + name, + elements = {}, + observedAttributes = [], + shadow = true, + } = metadata; + const template = document.getElementById(name); + + class CustomElement extends ComponentClass { + elements = {}; + + static get observedAttributes() { + return observedAttributes; + } + + constructor() { + super(); + if (shadow) { + const root = this.attachShadow({ mode: 'open' }); + if (template) root.append(template.content.cloneNode(true)); + for (const [prop, id] of Object.entries(elements)) { + this.elements[prop] = root.getElementById(id); + } + } + } + } + + customElements.define(name, CustomElement); + return CustomElement; +}; From 1b38f008e50284e60222b7329aa754c5996a00a3 Mon Sep 17 00:00:00 2001 From: David Totrashvili Date: Fri, 12 Jun 2026 13:42:26 +0500 Subject: [PATCH 3/3] Fix element access to use this.elements after rebase Resolve leftover direct this. access that survived conflict resolution and would be undefined on the refactored components: - profile-search: this.nameInput/this.emailInput - profile-directory: this.list - profile-item: this.openBtn/this.deleteBtn Co-Authored-By: Claude Opus 4.8 (1M context) --- static/components/profile-directory.mjs | 2 +- static/components/profile-item.mjs | 4 ++-- static/components/profile-search.mjs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/static/components/profile-directory.mjs b/static/components/profile-directory.mjs index 1a17285..7b2f1e4 100644 --- a/static/components/profile-directory.mjs +++ b/static/components/profile-directory.mjs @@ -19,7 +19,7 @@ class ProfileDirectory extends HTMLElement { }), ); }); - this.list.addEventListener('delete-profile', (event) => { + this.elements.list.addEventListener('delete-profile', (event) => { this.removeProfile(event.detail.id); }); this.elements.createBtn.addEventListener('click', () => { diff --git a/static/components/profile-item.mjs b/static/components/profile-item.mjs index a97fe0a..1809f48 100644 --- a/static/components/profile-item.mjs +++ b/static/components/profile-item.mjs @@ -3,7 +3,7 @@ import { defineCustomElement } from '/define-custom-element.mjs'; class ProfileItem extends HTMLElement { connectedCallback() { this.render(); - this.openBtn.addEventListener('click', () => { + this.elements.openBtn.addEventListener('click', () => { const event = new CustomEvent('open-profile', { detail: { id: this.getAttribute('profile-id') || '' }, bubbles: true, @@ -11,7 +11,7 @@ class ProfileItem extends HTMLElement { }); this.dispatchEvent(event); }); - this.deleteBtn.addEventListener('click', () => { + this.elements.deleteBtn.addEventListener('click', () => { const event = new CustomEvent('delete-profile', { detail: { id: this.getAttribute('profile-id') || '' }, bubbles: true, diff --git a/static/components/profile-search.mjs b/static/components/profile-search.mjs index a05342c..3603c34 100644 --- a/static/components/profile-search.mjs +++ b/static/components/profile-search.mjs @@ -9,8 +9,8 @@ class ProfileSearch extends HTMLElement { this.timer = setTimeout(() => { const event = new CustomEvent('search-change', { detail: { - name: this.nameInput.value, - email: this.emailInput.value, + name: this.elements.nameInput.value, + email: this.elements.emailInput.value, }, bubbles: true, composed: true,