diff --git a/server.js b/server.js new file mode 100644 index 0000000..035e9b1 --- /dev/null +++ b/server.js @@ -0,0 +1,318 @@ +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'); +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 sendJson = (res, status, payload) => { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); +}; + +const sendText = (res, status, text) => { + res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(text); +}; + +const insideProject = (candidatePath) => { + const rel = path.relative(PROJECT_ROOT, 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; +}; + +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'); + } + } + if (!raw.trim()) return {}; + try { + return JSON.parse(raw); + } catch { + throw new Error('INVALID_JSON'); + } +}; + +const usernameToPath = (username) => { + if (!USERNAME_RE.test(username)) return null; + return path.join(PROFILE_DIR, `${username}.json`); +}; + +const readProfile = async (username) => { + const target = usernameToPath(username); + if (!target) return null; + const raw = await readFile(target, 'utf8'); + return JSON.parse(raw); +}; + +const readDirectoryItems = async () => { + const files = await readdir(PROFILE_DIR); + return files.filter((file) => file.endsWith('.json')); +}; + +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); + } + + items.sort((a, b) => a.id.localeCompare(b.id)); + if (!nameQuery && !emailQuery) return items.slice(0, 10); + return items; +}; + +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 payload = incoming || {}; + const merged = { + ...payload, + 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, + }, + }; + } + + 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; + } + } + + 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 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 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'); + } catch (error) { + console.error(error); + return sendJson(res, 500, { ok: false, error: 'Unexpected server error' }); + } +}); + +server.listen(PORT, HOST, () => { + console.log(`Server listening on http://${HOST}:${PORT}`); +}); diff --git a/shared/profile-domain.mjs b/shared/profile-domain.mjs new file mode 100644 index 0000000..5c3996f --- /dev/null +++ b/shared/profile-domain.mjs @@ -0,0 +1,233 @@ +const clampInteger = (value, fallback = 0) => { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.trunc(number); +}; + +const clampNumber = (value, fallback = 0) => { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return number; +}; + +const toString = (value) => { + if (typeof value === 'string') return value.trim(); + if (value === null || value === undefined) return ''; + return String(value).trim(); +}; + +const normalizeSkills = (value) => { + if (!Array.isArray(value)) return []; + return value.map(toString).filter((item) => item.length > 0); +}; + +const PROFILE_FIELDS_METADATA = { + id: { + type: 'string', + pattern: /^[a-z0-9][a-z0-9-]{1,63}$/i, + message: 'Username contains unsupported characters', + }, + firstName: { type: 'string' }, + lastName: { type: 'string' }, + email: { type: 'string' }, + country: { type: 'string' }, + city: { type: 'string' }, + birthDate: { type: 'string' }, + experienceYears: { + type: 'integer', + min: 0, + max: 60, + message: 'Experience years must be an integer between 0 and 60', + }, + primarySkill: { type: 'string' }, + secondarySkills: { type: 'array' }, + weeklyAvailabilityHours: { + type: 'integer', + min: 0, + max: 80, + message: 'Weekly availability must be an integer between 0 and 80', + }, + hourlyRate: { + type: 'number', + min: 0, + max: 1000, + message: 'Hourly rate must be between 0 and 1000', + }, + currency: { type: 'string' }, + bio: { type: 'string' }, +}; + +const normalizeByMetadata = (key, metadata, value) => { + if (metadata.type === 'array') return normalizeSkills(value); + if (metadata.type === 'integer') return clampInteger(value, 0); + if (metadata.type === 'number') { + return clampNumber(value, 0); + } + const text = toString(value); + if (key === 'email') return text.toLowerCase(); + if (key === 'currency') return text.toUpperCase(); + return text; +}; + +const matchesExpectedType = (value, expectedType) => { + if (expectedType === 'array') return Array.isArray(value); + if (expectedType === 'integer') return Number.isInteger(value); + if (expectedType === 'number') return Number.isFinite(value); + return typeof value === expectedType; +}; + +const validateField = (value, metadata) => { + if (metadata.type !== 'number' && metadata.type !== 'integer') return null; + const outOfRange = value < metadata.min || value > metadata.max; + const wrongKind = + metadata.type === 'integer' + ? !Number.isInteger(value) + : !Number.isFinite(value); + return wrongKind || outOfRange ? metadata.message : null; +}; + +const isValidDate = (value) => + value instanceof Date && !Number.isNaN(value.valueOf()); + +const parseDate = (value) => { + if (typeof value !== 'string' || value.trim().length === 0) return null; + const parsed = new Date(value); + if (!isValidDate(parsed)) return null; + return parsed; +}; + +const calculateAge = (birthDate, now) => { + if (!isValidDate(birthDate) || !isValidDate(now)) return null; + let age = now.getUTCFullYear() - birthDate.getUTCFullYear(); + const monthDelta = now.getUTCMonth() - birthDate.getUTCMonth(); + const dayDelta = now.getUTCDate() - birthDate.getUTCDate(); + if (monthDelta < 0 || (monthDelta === 0 && dayDelta < 0)) { + age -= 1; + } + return age >= 0 ? age : null; +}; + +const toSlug = (firstName, lastName) => { + const raw = `${firstName} ${lastName}`.trim().toLowerCase(); + if (!raw) return ''; + return raw + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +}; + +const seniorityFromExperience = (years) => { + const levels = [ + { maxYears: 1, label: 'Junior' }, + { maxYears: 4, label: 'Middle' }, + { maxYears: 9, label: 'Senior' }, + ]; + const match = levels.find(({ maxYears }) => years <= maxYears); + return match ? match.label : 'Principal'; +}; + +const completeness = (profile) => { + const profileFieldKeys = Object.keys(PROFILE_FIELDS_METADATA); + 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); } + return acc + (toString(value).length > 0 ? 1 : 0); + }, 0); + return Math.round((filled / profileFieldKeys.length) * 100); +}; + +const normalizeProfile = (profile) => { + const source = profile && typeof profile === 'object' ? profile : {}; + const normalized = {}; + for (const [key, metadata] of Object.entries(PROFILE_FIELDS_METADATA)) { + normalized[key] = normalizeByMetadata(key, metadata, source[key]); + } + return normalized; +}; + +const validateProfile = (profile, now = new Date()) => { + const errors = {}; + 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 hasInvalidIdPattern = + normalized.id && + hasIdPattern && + !PROFILE_FIELDS_METADATA.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)) { + if (!matchesExpectedType(normalized[key], metadata.type)) { + errors[key] = `Expected ${metadata.type} value`; + } + } + + if (!normalized.id) errors.id = 'Username is required'; + if (hasInvalidIdPattern) { + errors.id = PROFILE_FIELDS_METADATA.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)) { + const fieldError = validateField(normalized[key], metadata); + if (fieldError) errors[key] = fieldError; + } + + if (!birthDate) { + errors.birthDate = 'Birth date is required and must be valid'; + } else if (birthDate >= today) { + errors.birthDate = 'Birth date must be in the past'; + } + + if (hasEmptySecondarySkill) { + errors.secondarySkills = 'Secondary skills must contain non-empty strings'; + } + + return errors; +}; + +const calculateProfile = (profile, now = new Date()) => { + const normalized = normalizeProfile(profile); + const birthDate = parseDate(normalized.birthDate); + const age = calculateAge(birthDate, now); + const displayName = `${normalized.firstName} ${normalized.lastName}`.trim(); + const monthlyCapacityHours = normalized.weeklyAvailabilityHours * 4; + const estimatedMonthlyIncome = monthlyCapacityHours * normalized.hourlyRate; + + return { + displayName, + age, + seniorityLevel: seniorityFromExperience(normalized.experienceYears), + monthlyCapacityHours, + estimatedMonthlyIncome, + profileCompleteness: completeness(normalized), + publicSlug: toSlug(normalized.firstName, normalized.lastName), + }; +}; + +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, + }; +}; + +export { + PROFILE_FIELDS_METADATA, + normalizeProfile, + validateProfile, + calculateProfile, + buildProfileState, +}; diff --git a/static/app.mjs b/static/app.mjs new file mode 100644 index 0000000..10da059 --- /dev/null +++ b/static/app.mjs @@ -0,0 +1,10 @@ +import './components/profile-app.mjs'; +import './components/profile-form.mjs'; +import './components/profile-field.mjs'; +import './components/profile-summary.mjs'; +import './components/validation-message.mjs'; +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.mjs b/static/components/profile-app.mjs new file mode 100644 index 0000000..47449a7 --- /dev/null +++ b/static/components/profile-app.mjs @@ -0,0 +1,123 @@ +const template = document.createElement('template'); +template.innerHTML = ` + +
+ Professional Profiles + Directory +
+
+`; + +class ProfileApp extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + this.main = this.shadowRoot.getElementById('main'); + this.homeLink = this.shadowRoot.getElementById('homeLink'); + } + + connectedCallback() { + this.homeLink.addEventListener('click', (event) => { + event.preventDefault(); + this.go('/'); + }); + + this.shadowRoot.addEventListener('navigate-profile', (event) => { + this.go(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.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); + + if (pathname === '/') { + const directory = document.createElement('profile-directory'); + this.main.append(directory); + return; + } + + if (pathname.startsWith('/profile/')) { + const username = pathname.slice('/profile/'.length); + await this.renderProfile(username); + return; + } + + const message = document.createElement('div'); + message.className = 'error'; + message.textContent = 'Route not found'; + this.main.append(message); + } + + async renderProfile(username) { + const res = await fetch(`/profile/${encodeURIComponent(username)}`, { + headers: { Accept: 'application/json' }, + }); + 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); + return; + } + const form = document.createElement('profile-form'); + form.state = body; + this.main.append(form); + } +} + +customElements.define('profile-app', ProfileApp); diff --git a/static/components/profile-create-dialog.mjs b/static/components/profile-create-dialog.mjs new file mode 100644 index 0000000..2213638 --- /dev/null +++ b/static/components/profile-create-dialog.mjs @@ -0,0 +1,90 @@ +import { buildProfileState } from '/shared/profile-domain.mjs'; + +const template = document.createElement('template'); +template.innerHTML = ` + + +

Create profile

+ +
+ + +
+
+`; + +class ProfileCreateDialog extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + 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({}); + } + + connectedCallback() { + this.form.state = this.state; + this.form.editableId = true; + this.form.addEventListener('profile-state-change', (event) => { + this.state = event.detail.state; + this.createBtn.disabled = !this.state.valid; + }); + this.cancelBtn.addEventListener('click', () => this.close()); + this.createBtn.addEventListener('click', () => this.submit()); + } + + open() { + this.state = buildProfileState({}); + this.form.state = this.state; + this.createBtn.disabled = !this.state.valid; + this.dialog.showModal(); + } + + close() { + this.dialog.close(); + } + + 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 }; + } + return; + } + this.dispatchEvent( + new CustomEvent('profile-created', { + detail: { id: body.profile.id }, + bubbles: true, + composed: true, + }), + ); + this.close(); + } +} + +customElements.define('profile-create-dialog', ProfileCreateDialog); diff --git a/static/components/profile-directory.mjs b/static/components/profile-directory.mjs new file mode 100644 index 0000000..331ed3b --- /dev/null +++ b/static/components/profile-directory.mjs @@ -0,0 +1,98 @@ +const template = document.createElement('template'); +template.innerHTML = ` + +
+

Profile Directory

+ +
+
+ + +
+ +`; + +class ProfileDirectory extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + 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: '' }; + } + + connectedCallback() { + this.load(); + this.search.addEventListener('search-change', (event) => { + this.query = event.detail; + this.load(); + }); + this.list.addEventListener('open-profile', (event) => { + this.dispatchEvent( + new CustomEvent('navigate-profile', { + detail: { path: `/profile/${event.detail.id}` }, + bubbles: true, + composed: true, + }), + ); + }); + 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.dispatchEvent( + new CustomEvent('navigate-profile', { + detail: { path: `/profile/${event.detail.id}` }, + bubbles: true, + composed: true, + }), + ); + }); + } + + 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 || []; + } + + 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(); + } +} + +customElements.define('profile-directory', ProfileDirectory); diff --git a/static/components/profile-field.mjs b/static/components/profile-field.mjs new file mode 100644 index 0000000..ed9467b --- /dev/null +++ b/static/components/profile-field.mjs @@ -0,0 +1,116 @@ +const template = document.createElement('template'); +template.innerHTML = ` + + + + +`; + +class ProfileField extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + 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, + }), + ); + }); + } + + static get observedAttributes() { + return ['name', 'label', 'type', 'value', 'error', 'multiline', 'disabled']; + } + + connectedCallback() { + this.render(); + } + + attributeChangedCallback() { + this.render(); + } + + get name() { + return this.getAttribute('name') || ''; + } + + get value() { + return this.control.value; + } + + render() { + const multiline = this.hasAttribute('multiline'); + const disabled = this.hasAttribute('disabled'); + const controlId = `field-${this.name}`; + + 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.control.id = controlId; + this.labelEl.setAttribute('for', controlId); + this.labelEl.textContent = this.getAttribute('label') || this.name; + + 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') || ''); + } +} + +customElements.define('profile-field', ProfileField); diff --git a/static/components/profile-form.mjs b/static/components/profile-form.mjs new file mode 100644 index 0000000..141aec1 --- /dev/null +++ b/static/components/profile-form.mjs @@ -0,0 +1,225 @@ +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

+
+ +
+ + +
+
+`; + +class ProfileForm extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + this.formEl = this.shadowRoot.getElementById('form'); + 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; + } + + connectedCallback() { + this.renderFields(); + this.formEl.addEventListener('submit', (event) => { + event.preventDefault(); + this.handleSave(); + }); + this.formEl.addEventListener('field-change', (event) => { + this.updateField(event.detail.name, event.detail.value); + }); + this.render(); + } + + set state(value) { + this._state = value; + this._serverErrors = {}; + this.render(); + } + + get state() { + return this._state; + } + + set serverErrors(value) { + this._serverErrors = value || {}; + this.render(); + } + + set editableId(value) { + this._editableId = Boolean(value); + this.render(); + } + + renderFields() { + while (this.fieldsEl.firstChild) { this.fieldsEl.removeChild(this.fieldsEl.firstChild); } + for (const { name, label, type } of FIELDS) { + const field = document.createElement('profile-field'); + field.setAttribute('name', name); + field.setAttribute('label', label); + field.setAttribute('type', type); + if (name === 'bio') field.setAttribute('multiline', ''); + this.fieldsEl.append(field); + this.fieldEls.set(name, field); + } + } + + updateField(name, value) { + const next = { ...this.state.profile }; + if (name === 'secondarySkills') { + next[name] = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + } else if ( + PROFILE_FIELDS_METADATA[name]?.type === 'number' || + PROFILE_FIELDS_METADATA[name]?.type === 'integer' + ) { + next[name] = value === '' ? 0 : Number(value); + } else { + next[name] = value; + } + + this._state = buildProfileState(next); + this._serverErrors = {}; + this.dispatchEvent( + new CustomEvent('profile-state-change', { + detail: { state: this._state }, + bubbles: true, + composed: true, + }), + ); + this.render(); + } + + 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' }; + this.statusEl.textContent = ''; + return; + } + this._state = body; + this.statusEl.textContent = 'Saved'; + setTimeout(() => { + if (this.statusEl.textContent === 'Saved') this.statusEl.textContent = ''; + }, 1500); + this.dispatchEvent( + new CustomEvent('profile-saved', { + detail: { state: this._state }, + bubbles: true, + composed: true, + }), + ); + this.render(); + } + + render() { + if (!this.isConnected) return; + const profile = this.state?.profile || {}; + const errors = { + ...this.state?.errors || {}, + ...this._serverErrors || {}, + }; + + for (const { name } of FIELDS) { + const field = this.fieldEls.get(name); + if (!field) continue; + if (name === 'secondarySkills') { + field.setAttribute( + 'value', + Array.isArray(profile[name]) ? profile[name].join(', ') : '', + ); + } else { + field.setAttribute( + 'value', + profile[name] === undefined || profile[name] === null + ? '' + : String(profile[name]), + ); + } + field.setAttribute('error', errors[name] || ''); + if (name === 'id' && !this._editableId) { field.setAttribute('disabled', ''); } + if (name !== 'id' || this._editableId) field.removeAttribute('disabled'); + } + + this.saveBtn.disabled = + !this.state.valid || this.getAttribute('mode') === 'create'; + this.summaryEl.data = this.state.computed || {}; + } +} + +customElements.define('profile-form', ProfileForm); diff --git a/static/components/profile-item.mjs b/static/components/profile-item.mjs new file mode 100644 index 0000000..5f9241d --- /dev/null +++ b/static/components/profile-item.mjs @@ -0,0 +1,89 @@ +const template = document.createElement('template'); +template.innerHTML = ` + +
+
+
+ +
+
+ + +
+
+`; + +class ProfileItem extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + 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', () => { + this.dispatchEvent( + new CustomEvent('open-profile', { + detail: { id: this.getAttribute('profile-id') || '' }, + bubbles: true, + composed: true, + }), + ); + }); + this.deleteBtn.addEventListener('click', () => { + this.dispatchEvent( + new CustomEvent('delete-profile', { + detail: { id: this.getAttribute('profile-id') || '' }, + bubbles: true, + composed: true, + }), + ); + }); + } + + static get observedAttributes() { + return ['display-name', 'email']; + } + + attributeChangedCallback() { + this.render(); + } + + render() { + this.nameEl.textContent = this.getAttribute('display-name') || ''; + this.emailEl.textContent = this.getAttribute('email') || ''; + } +} + +customElements.define('profile-item', ProfileItem); diff --git a/static/components/profile-list.mjs b/static/components/profile-list.mjs new file mode 100644 index 0000000..f4abebf --- /dev/null +++ b/static/components/profile-list.mjs @@ -0,0 +1,33 @@ +class ProfileList extends HTMLElement { + set items(value) { + this._items = Array.isArray(value) ? value : []; + this.render(); + } + + get items() { + return this._items || []; + } + + connectedCallback() { + this.render(); + } + + render() { + while (this.firstChild) this.removeChild(this.firstChild); + if (this.items.length === 0) { + const empty = document.createElement('p'); + empty.textContent = 'No profiles found.'; + this.append(empty); + return; + } + for (const item of this.items) { + 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); + } + } +} + +customElements.define('profile-list', ProfileList); diff --git a/static/components/profile-search.mjs b/static/components/profile-search.mjs new file mode 100644 index 0000000..feda424 --- /dev/null +++ b/static/components/profile-search.mjs @@ -0,0 +1,58 @@ +const template = document.createElement('template'); +template.innerHTML = ` + +
+ + +
+`; + +class ProfileSearch extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + this.nameInput = this.shadowRoot.getElementById('name'); + this.emailInput = this.shadowRoot.getElementById('email'); + this.timer = null; + } + + connectedCallback() { + const emit = () => { + if (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, + }), + ); + }, 250); + }; + this.nameInput.addEventListener('input', emit); + this.emailInput.addEventListener('input', emit); + } +} + +customElements.define('profile-search', ProfileSearch); diff --git a/static/components/profile-summary.mjs b/static/components/profile-summary.mjs new file mode 100644 index 0000000..e2e7392 --- /dev/null +++ b/static/components/profile-summary.mjs @@ -0,0 +1,91 @@ +const template = document.createElement('template'); +template.innerHTML = ` + +
Display:
+
Age:
+
Seniority:
+
Monthly Capacity:
+
Monthly Income:
+
Completeness:
+
Public Slug:
+`; + +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); + } + } + + set data(value) { + this._data = value || {}; + this.render(); + } + + connectedCallback() { + this.upgradeProperty('data'); + 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 || '-'; + } +} + +customElements.define('profile-summary', ProfileSummary); diff --git a/static/components/validation-message.mjs b/static/components/validation-message.mjs new file mode 100644 index 0000000..dd26362 --- /dev/null +++ b/static/components/validation-message.mjs @@ -0,0 +1,41 @@ +const template = document.createElement('template'); +template.innerHTML = ` + + +`; + +class ValidationMessage extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot.append(template.content.cloneNode(true)); + this.textEl = this.shadowRoot.getElementById('text'); + } + + static get observedAttributes() { + return ['message']; + } + + attributeChangedCallback() { + this.render(); + } + + connectedCallback() { + this.render(); + } + + render() { + const message = this.getAttribute('message') || ''; + this.textEl.textContent = message; + } +} + +customElements.define('validation-message', ValidationMessage); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..8738e24 --- /dev/null +++ b/static/index.html @@ -0,0 +1,13 @@ + + + + + + Professional Profiles + + + + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..7aadd3c --- /dev/null +++ b/static/styles.css @@ -0,0 +1,16 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + background: #f4f6f8; + color: #20232a; +} + +button, +input, +textarea { + font: inherit; +}