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..d4849af 100644
--- a/static/components/profile-app.mjs
+++ b/static/components/profile-app.mjs
@@ -1,92 +1,41 @@
-const template = document.createElement('template');
-template.innerHTML = `
-
-
-
-`;
+import { getProfile } from '/api.mjs';
+import { buildProfileState } from '/shared/profile-domain.mjs';
+import { defineCustomElement } from '/define-custom-element.mjs';
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) => {
+ this.elements.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.elements.main.replaceChildren();
if (pathname === '/') {
- const directory = document.createElement('profile-directory');
- this.main.append(directory);
+ this.elements.main.append(document.createElement('profile-directory'));
+ return;
+ }
+
+ if (pathname === '/new') {
+ this.renderCreate();
return;
}
@@ -96,28 +45,39 @@ 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.elements.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.elements.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.elements.main.append(error);
return;
}
const form = document.createElement('profile-form');
- form.state = body;
- this.main.append(form);
+ form.state = result;
+ 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.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..1394ff8 100644
--- a/static/components/profile-create-dialog.mjs
+++ b/static/components/profile-create-dialog.mjs
@@ -1,84 +1,42 @@
import { buildProfileState } from '/shared/profile-domain.mjs';
-
-const template = document.createElement('template');
-template.innerHTML = `
-
-
-`;
+import { createProfile } from '/api.mjs';
+import { defineCustomElement } from '/define-custom-element.mjs';
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({});
- }
+ 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 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.elements.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,
}),
@@ -87,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.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..7b2f1e4 100644
--- a/static/components/profile-directory.mjs
+++ b/static/components/profile-directory.mjs
@@ -1,55 +1,16 @@
-const template = document.createElement('template');
-template.innerHTML = `
-
-
-
-
-`;
+import { searchProfiles, deleteProfile } from '/api.mjs';
+import { defineCustomElement } from '/define-custom-element.mjs';
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: '' };
- }
+ 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}` },
@@ -58,15 +19,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.elements.list.addEventListener('delete-profile', (event) => {
+ this.removeProfile(event.detail.id);
+ });
+ this.elements.createBtn.addEventListener('click', () => {
this.dispatchEvent(
new CustomEvent('navigate-profile', {
- detail: { path: `/profile/${event.detail.id}` },
+ detail: { path: '/new' },
bubbles: true,
composed: true,
}),
@@ -75,24 +34,18 @@ 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.elements.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();
}
}
-customElements.define('profile-directory', ProfileDirectory);
+defineCustomElement(ProfileDirectory, {
+ name: 'profile-directory',
+ elements: { search: 'search', list: 'list', createBtn: 'create' },
+});
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..a9f3c95 100644
--- a/static/components/profile-field.mjs
+++ b/static/components/profile-field.mjs
@@ -1,52 +1,17 @@
-const template = document.createElement('template');
-template.innerHTML = `
-
-
-
-
-`;
+import { defineCustomElement } from '/define-custom-element.mjs';
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() {
+ const emit = () => {
+ const event = new CustomEvent('field-change', {
+ detail: { name: this.fieldName, value: this.value },
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(event);
+ };
+ this.elements.inputEl.addEventListener('input', emit);
+ this.elements.textareaEl.addEventListener('input', emit);
this.render();
}
@@ -54,63 +19,57 @@ 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.elements.textareaEl.value
+ : this.elements.inputEl.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,
- }),
- );
- });
- }
+ const id = `field-${this.fieldName}`;
- 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.elements.inputEl.hidden = multiline;
+ this.elements.textareaEl.hidden = !multiline;
- this.control.id = controlId;
- this.labelEl.setAttribute('for', controlId);
- this.labelEl.textContent = this.getAttribute('label') || this.name;
+ const active = multiline ? this.elements.textareaEl : this.elements.inputEl;
+ this.elements.labelEl.setAttribute('for', id);
+ active.id = id;
+ this.elements.labelEl.textContent =
+ this.getAttribute('label') || this.fieldName;
- if (this.control.tagName === 'INPUT') {
- this.control.type = this.getAttribute('type') || 'text';
+ if (!multiline) {
+ this.elements.inputEl.type = this.getAttribute('type') || 'text';
}
- this.control.value = this.getAttribute('value') || '';
- this.control.disabled = disabled;
- this.error.setAttribute('message', this.getAttribute('error') || '');
+ active.value = this.getAttribute('value') || '';
+ active.disabled = disabled;
+ 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.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..88cf57a 100644
--- a/static/components/profile-form.mjs
+++ b/static/components/profile-form.mjs
@@ -1,95 +1,20 @@
-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 = `
-
-
-`;
+import { profileFields, buildProfileState } from '/shared/profile-domain.mjs';
+import { saveProfile, createProfile } from '/api.mjs';
+import { defineCustomElement } from '/define-custom-element.mjs';
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;
- }
+ 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();
@@ -115,17 +40,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.elements.fieldsEl.replaceChildren(...nodes);
}
updateField(name, value) {
@@ -133,11 +73,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,25 +97,37 @@ 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' };
- this.statusEl.textContent = '';
+ 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.elements.statusEl.textContent = '';
return;
}
- this._state = body;
- this.statusEl.textContent = 'Saved';
+ this._state = result;
+ 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', {
@@ -190,36 +142,48 @@ 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 };
+
+ let title = 'Profile';
+ if (this.isCreate) title = 'Create Profile';
+ else if (profile.id) title = `Edit: ${profile.id}`;
+ this.elements.titleEl.textContent = title;
+
+ this.elements.saveBtn.textContent = this.isCreate ? 'Create' : 'Save';
+ this.elements.saveBtn.disabled = !this.state.valid;
- for (const { name } of FIELDS) {
+ 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 || {};
+ 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.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..1809f48 100644
--- a/static/components/profile-item.mjs
+++ b/static/components/profile-item.mjs
@@ -1,89 +1,43 @@
-const template = document.createElement('template');
-template.innerHTML = `
-
-
-`;
+import { defineCustomElement } from '/define-custom-element.mjs';
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.elements.openBtn.addEventListener('click', () => {
+ 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,
- }),
- );
+ this.elements.deleteBtn.addEventListener('click', () => {
+ const event = new CustomEvent('delete-profile', {
+ detail: { id: this.getAttribute('profile-id') || '' },
+ bubbles: true,
+ composed: true,
+ });
+ this.dispatchEvent(event);
});
}
- 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 f4abebf..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 : [];
@@ -13,21 +15,25 @@ 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);
}
}
-customElements.define('profile-list', ProfileList);
+defineCustomElement(ProfileList, {
+ name: 'profile-list',
+ shadow: false,
+});
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..3603c34 100644
--- a/static/components/profile-search.mjs
+++ b/static/components/profile-search.mjs
@@ -1,58 +1,29 @@
-const template = document.createElement('template');
-template.innerHTML = `
-
-
-
-
-
-`;
+import { defineCustomElement } from '/define-custom-element.mjs';
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;
- }
+ timer = null;
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.elements.nameInput.value,
+ email: this.elements.emailInput.value,
+ },
+ bubbles: true,
+ composed: true,
+ });
+ 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.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 @@
+
+
+ Computed
+ Name-
+ Age-
+ Seniority-
+ Capacity-
+ Est. income-
+ Complete-
+ Slug-
+
diff --git a/static/components/profile-summary.mjs b/static/components/profile-summary.mjs
index e2e7392..19dc52e 100644
--- a/static/components/profile-summary.mjs
+++ b/static/components/profile-summary.mjs
@@ -1,91 +1,31 @@
-const template = document.createElement('template');
-template.innerHTML = `
-
- Display:
- Age:
- Seniority:
- Monthly Capacity:
- Monthly Income:
- Completeness:
- Public Slug:
-`;
+import { profileFields } from '/shared/profile-domain.mjs';
-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);
- }
- }
+import { defineCustomElement } from '/define-custom-element.mjs';
+class ProfileSummary extends HTMLElement {
set data(value) {
this._data = value || {};
this.render();
}
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] ?? '-');
+ }
}
}
-customElements.define('profile-summary', ProfileSummary);
+defineCustomElement(ProfileSummary, { name: 'profile-summary' });
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..80dd176 100644
--- a/static/components/validation-message.mjs
+++ b/static/components/validation-message.mjs
@@ -1,29 +1,6 @@
-const template = document.createElement('template');
-template.innerHTML = `
-
-
-`;
+import { defineCustomElement } from '/define-custom-element.mjs';
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();
}
@@ -33,9 +10,12 @@ class ValidationMessage extends HTMLElement {
}
render() {
- const message = this.getAttribute('message') || '';
- this.textEl.textContent = 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;
+};
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 @@
+