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 = `
+
+
+
+`;
+
+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 = `
+
+
+`;
+
+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 = `
+
+
+
+
+`;
+
+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 = `
+
+
+`;
+
+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;
+}