@rmdes/indiekit-endpoint-cv 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,198 @@
1
+ import express from "express";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ import { dashboardController } from "./lib/controllers/dashboard.js";
6
+ import { apiController } from "./lib/controllers/api.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ const protectedRouter = express.Router();
11
+ const publicRouter = express.Router();
12
+
13
+ const defaults = {
14
+ mountPath: "/cv",
15
+ };
16
+
17
+ export default class CvEndpoint {
18
+ name = "CV editor endpoint";
19
+
20
+ constructor(options = {}) {
21
+ this.options = { ...defaults, ...options };
22
+ this.mountPath = this.options.mountPath;
23
+ }
24
+
25
+ get localesDirectory() {
26
+ return path.join(__dirname, "locales");
27
+ }
28
+
29
+ get viewsDirectory() {
30
+ return path.join(__dirname, "views");
31
+ }
32
+
33
+ get navigationItems() {
34
+ return {
35
+ href: this.options.mountPath,
36
+ text: "cv.title",
37
+ requiresDatabase: true,
38
+ };
39
+ }
40
+
41
+ get shortcutItems() {
42
+ return {
43
+ url: this.options.mountPath,
44
+ name: "cv.title",
45
+ iconName: "briefcase",
46
+ requiresDatabase: true,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Register CV section types for homepage plugin discovery
52
+ */
53
+ get homepageSections() {
54
+ return [
55
+ {
56
+ id: "cv-experience",
57
+ label: "Work Experience",
58
+ description: "Professional experience timeline",
59
+ icon: "briefcase",
60
+ dataEndpoint: "/cv/data.json",
61
+ defaultConfig: {
62
+ maxItems: 10,
63
+ showHighlights: true,
64
+ },
65
+ configSchema: {
66
+ maxItems: {
67
+ type: "number",
68
+ label: "Max items",
69
+ min: 1,
70
+ max: 50,
71
+ },
72
+ showHighlights: {
73
+ type: "boolean",
74
+ label: "Show highlights",
75
+ },
76
+ },
77
+ },
78
+ {
79
+ id: "cv-skills",
80
+ label: "Skills",
81
+ description: "Skills grouped by category",
82
+ icon: "zap",
83
+ dataEndpoint: "/cv/data.json",
84
+ defaultConfig: {},
85
+ configSchema: {},
86
+ },
87
+ {
88
+ id: "cv-education",
89
+ label: "Education & Languages",
90
+ description: "Academic background and languages",
91
+ icon: "book",
92
+ dataEndpoint: "/cv/data.json",
93
+ defaultConfig: {},
94
+ configSchema: {},
95
+ },
96
+ {
97
+ id: "cv-projects",
98
+ label: "Projects",
99
+ description: "Personal and professional projects",
100
+ icon: "folder",
101
+ dataEndpoint: "/cv/data.json",
102
+ defaultConfig: {
103
+ maxItems: 10,
104
+ showTechnologies: true,
105
+ },
106
+ configSchema: {
107
+ maxItems: {
108
+ type: "number",
109
+ label: "Max items",
110
+ min: 1,
111
+ max: 50,
112
+ },
113
+ showTechnologies: {
114
+ type: "boolean",
115
+ label: "Show technologies",
116
+ },
117
+ },
118
+ },
119
+ {
120
+ id: "cv-interests",
121
+ label: "Interests",
122
+ description: "Topics and hobbies",
123
+ icon: "heart",
124
+ dataEndpoint: "/cv/data.json",
125
+ defaultConfig: {},
126
+ configSchema: {},
127
+ },
128
+ ];
129
+ }
130
+
131
+ /**
132
+ * Protected routes (require authentication)
133
+ */
134
+ get routes() {
135
+ // Dashboard - main admin UI
136
+ protectedRouter.get("/", dashboardController.get);
137
+
138
+ // Save CV data
139
+ protectedRouter.post("/save", dashboardController.save);
140
+
141
+ // CRUD for individual sections
142
+ protectedRouter.post("/experience/add", dashboardController.addExperience);
143
+ protectedRouter.post(
144
+ "/experience/:index/delete",
145
+ dashboardController.deleteExperience,
146
+ );
147
+
148
+ protectedRouter.post("/projects/add", dashboardController.addProject);
149
+ protectedRouter.post(
150
+ "/projects/:index/delete",
151
+ dashboardController.deleteProject,
152
+ );
153
+
154
+ protectedRouter.post("/education/add", dashboardController.addEducation);
155
+ protectedRouter.post(
156
+ "/education/:index/delete",
157
+ dashboardController.deleteEducation,
158
+ );
159
+
160
+ protectedRouter.post("/languages/add", dashboardController.addLanguage);
161
+ protectedRouter.post(
162
+ "/languages/:index/delete",
163
+ dashboardController.deleteLanguage,
164
+ );
165
+
166
+ protectedRouter.post("/skills/add", dashboardController.addSkillCategory);
167
+ protectedRouter.post(
168
+ "/skills/:category/delete",
169
+ dashboardController.deleteSkillCategory,
170
+ );
171
+
172
+ return protectedRouter;
173
+ }
174
+
175
+ /**
176
+ * Public routes (no authentication required)
177
+ */
178
+ get routesPublic() {
179
+ // Public JSON API for Eleventy and homepage plugin
180
+ publicRouter.get("/data.json", apiController.getData);
181
+
182
+ return publicRouter;
183
+ }
184
+
185
+ init(Indiekit) {
186
+ Indiekit.addEndpoint(this);
187
+
188
+ // Add MongoDB collection for CV data
189
+ Indiekit.addCollection("cvData");
190
+
191
+ // Store config in application for controller access
192
+ Indiekit.config.application.cvConfig = this.options;
193
+ Indiekit.config.application.cvEndpoint = this.mountPath;
194
+
195
+ // Store database getter for controller access
196
+ Indiekit.config.application.getCvDb = () => Indiekit.database;
197
+ }
198
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Public API controller
3
+ * Serves CV data as JSON for Eleventy and homepage plugin
4
+ */
5
+
6
+ import { getCvData, getDefaultCvData } from "../storage/cv.js";
7
+
8
+ export const apiController = {
9
+ /**
10
+ * GET /cv/data.json - Public CV data endpoint
11
+ */
12
+ async getData(request, response) {
13
+ const { application } = request.app.locals;
14
+
15
+ try {
16
+ const data = (await getCvData(application)) || getDefaultCvData();
17
+
18
+ // Strip MongoDB internal fields
19
+ const { _id, ...cvData } = data;
20
+
21
+ response.json(cvData);
22
+ } catch (error) {
23
+ console.error("[CV] API error:", error);
24
+ response.json(getDefaultCvData());
25
+ }
26
+ },
27
+ };
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Dashboard controller
3
+ * Admin UI for CV data management
4
+ */
5
+
6
+ import {
7
+ getCvData,
8
+ saveCvData,
9
+ getDefaultCvData,
10
+ addToSection,
11
+ removeFromSection,
12
+ addSkillCategory,
13
+ removeSkillCategory,
14
+ } from "../storage/cv.js";
15
+
16
+ export const dashboardController = {
17
+ /**
18
+ * GET / - Main dashboard
19
+ */
20
+ async get(request, response) {
21
+ const { application } = request.app.locals;
22
+
23
+ try {
24
+ const data = (await getCvData(application)) || getDefaultCvData();
25
+
26
+ response.render("cv-dashboard", {
27
+ title: "CV Editor",
28
+ cv: data,
29
+ cvEndpoint: application.cvEndpoint,
30
+ });
31
+ } catch (error) {
32
+ console.error("[CV] Dashboard error:", error);
33
+ response.status(500).render("error", {
34
+ title: "Error",
35
+ message: "Failed to load CV data",
36
+ error: error.message,
37
+ });
38
+ }
39
+ },
40
+
41
+ /**
42
+ * POST /save - Save full CV data (bulk save from form)
43
+ */
44
+ async save(request, response) {
45
+ const { application } = request.app.locals;
46
+
47
+ try {
48
+ const body = request.body;
49
+
50
+ // Parse interests from comma-separated string
51
+ const interests =
52
+ typeof body.interests === "string"
53
+ ? body.interests
54
+ .split(",")
55
+ .map((s) => s.trim())
56
+ .filter(Boolean)
57
+ : body.interests || [];
58
+
59
+ const data = {
60
+ experience: parseArrayField(body, "experience"),
61
+ projects: parseArrayField(body, "projects"),
62
+ skills: parseSkillsField(body),
63
+ education: parseArrayField(body, "education"),
64
+ languages: parseArrayField(body, "languages"),
65
+ interests,
66
+ };
67
+
68
+ await saveCvData(application, data);
69
+
70
+ response.redirect(application.cvEndpoint + "?saved=1");
71
+ } catch (error) {
72
+ console.error("[CV] Save error:", error);
73
+ response.status(500).render("error", {
74
+ title: "Error",
75
+ message: "Failed to save CV data",
76
+ error: error.message,
77
+ });
78
+ }
79
+ },
80
+
81
+ // --- Experience CRUD ---
82
+
83
+ async addExperience(request, response) {
84
+ const { application } = request.app.locals;
85
+ try {
86
+ const { title, company, location, startDate, endDate, type, description, highlights } =
87
+ request.body;
88
+ await addToSection(application, "experience", {
89
+ title: title || "",
90
+ company: company || "",
91
+ location: location || "",
92
+ startDate: startDate || "",
93
+ endDate: endDate || null,
94
+ type: type || "full-time",
95
+ description: description || "",
96
+ highlights: parseLines(highlights),
97
+ });
98
+ response.redirect(application.cvEndpoint + "?saved=1#experience");
99
+ } catch (error) {
100
+ console.error("[CV] Add experience error:", error);
101
+ response.redirect(application.cvEndpoint + "?error=1#experience");
102
+ }
103
+ },
104
+
105
+ async deleteExperience(request, response) {
106
+ const { application } = request.app.locals;
107
+ try {
108
+ await removeFromSection(application, "experience", Number.parseInt(request.params.index, 10));
109
+ response.redirect(application.cvEndpoint + "?saved=1#experience");
110
+ } catch (error) {
111
+ console.error("[CV] Delete experience error:", error);
112
+ response.redirect(application.cvEndpoint + "?error=1#experience");
113
+ }
114
+ },
115
+
116
+ // --- Projects CRUD ---
117
+
118
+ async addProject(request, response) {
119
+ const { application } = request.app.locals;
120
+ try {
121
+ const { name, url, description, technologies, status } = request.body;
122
+ await addToSection(application, "projects", {
123
+ name: name || "",
124
+ url: url || "",
125
+ description: description || "",
126
+ technologies: parseCommaList(technologies),
127
+ status: status || "active",
128
+ });
129
+ response.redirect(application.cvEndpoint + "?saved=1#projects");
130
+ } catch (error) {
131
+ console.error("[CV] Add project error:", error);
132
+ response.redirect(application.cvEndpoint + "?error=1#projects");
133
+ }
134
+ },
135
+
136
+ async deleteProject(request, response) {
137
+ const { application } = request.app.locals;
138
+ try {
139
+ await removeFromSection(application, "projects", Number.parseInt(request.params.index, 10));
140
+ response.redirect(application.cvEndpoint + "?saved=1#projects");
141
+ } catch (error) {
142
+ console.error("[CV] Delete project error:", error);
143
+ response.redirect(application.cvEndpoint + "?error=1#projects");
144
+ }
145
+ },
146
+
147
+ // --- Education CRUD ---
148
+
149
+ async addEducation(request, response) {
150
+ const { application } = request.app.locals;
151
+ try {
152
+ const { degree, institution, location, year, description } = request.body;
153
+ await addToSection(application, "education", {
154
+ degree: degree || "",
155
+ institution: institution || "",
156
+ location: location || "",
157
+ year: year || "",
158
+ description: description || "",
159
+ });
160
+ response.redirect(application.cvEndpoint + "?saved=1#education");
161
+ } catch (error) {
162
+ console.error("[CV] Add education error:", error);
163
+ response.redirect(application.cvEndpoint + "?error=1#education");
164
+ }
165
+ },
166
+
167
+ async deleteEducation(request, response) {
168
+ const { application } = request.app.locals;
169
+ try {
170
+ await removeFromSection(application, "education", Number.parseInt(request.params.index, 10));
171
+ response.redirect(application.cvEndpoint + "?saved=1#education");
172
+ } catch (error) {
173
+ console.error("[CV] Delete education error:", error);
174
+ response.redirect(application.cvEndpoint + "?error=1#education");
175
+ }
176
+ },
177
+
178
+ // --- Languages CRUD ---
179
+
180
+ async addLanguage(request, response) {
181
+ const { application } = request.app.locals;
182
+ try {
183
+ const { name, level } = request.body;
184
+ await addToSection(application, "languages", {
185
+ name: name || "",
186
+ level: level || "intermediate",
187
+ });
188
+ response.redirect(application.cvEndpoint + "?saved=1#languages");
189
+ } catch (error) {
190
+ console.error("[CV] Add language error:", error);
191
+ response.redirect(application.cvEndpoint + "?error=1#languages");
192
+ }
193
+ },
194
+
195
+ async deleteLanguage(request, response) {
196
+ const { application } = request.app.locals;
197
+ try {
198
+ await removeFromSection(application, "languages", Number.parseInt(request.params.index, 10));
199
+ response.redirect(application.cvEndpoint + "?saved=1#languages");
200
+ } catch (error) {
201
+ console.error("[CV] Delete language error:", error);
202
+ response.redirect(application.cvEndpoint + "?error=1#languages");
203
+ }
204
+ },
205
+
206
+ // --- Skills CRUD ---
207
+
208
+ async addSkillCategory(request, response) {
209
+ const { application } = request.app.locals;
210
+ try {
211
+ const { category, items } = request.body;
212
+ await addSkillCategory(application, category || "Uncategorized", parseCommaList(items));
213
+ response.redirect(application.cvEndpoint + "?saved=1#skills");
214
+ } catch (error) {
215
+ console.error("[CV] Add skill category error:", error);
216
+ response.redirect(application.cvEndpoint + "?error=1#skills");
217
+ }
218
+ },
219
+
220
+ async deleteSkillCategory(request, response) {
221
+ const { application } = request.app.locals;
222
+ try {
223
+ await removeSkillCategory(application, decodeURIComponent(request.params.category));
224
+ response.redirect(application.cvEndpoint + "?saved=1#skills");
225
+ } catch (error) {
226
+ console.error("[CV] Delete skill category error:", error);
227
+ response.redirect(application.cvEndpoint + "?error=1#skills");
228
+ }
229
+ },
230
+ };
231
+
232
+ // --- Helper functions ---
233
+
234
+ /**
235
+ * Parse comma-separated string into array
236
+ */
237
+ function parseCommaList(value) {
238
+ if (!value) return [];
239
+ return value
240
+ .split(",")
241
+ .map((s) => s.trim())
242
+ .filter(Boolean);
243
+ }
244
+
245
+ /**
246
+ * Parse newline-separated string into array
247
+ */
248
+ function parseLines(value) {
249
+ if (!value) return [];
250
+ return value
251
+ .split("\n")
252
+ .map((s) => s.trim())
253
+ .filter(Boolean);
254
+ }
255
+
256
+ /**
257
+ * Parse array fields from form body
258
+ * Handles indexed form fields like experience[0][title], experience[1][title]
259
+ */
260
+ function parseArrayField(body, fieldName) {
261
+ const field = body[fieldName];
262
+ if (!field) return [];
263
+ if (Array.isArray(field)) return field;
264
+ if (typeof field === "object") {
265
+ // Indexed object form: { "0": {...}, "1": {...} }
266
+ return Object.values(field);
267
+ }
268
+ return [];
269
+ }
270
+
271
+ /**
272
+ * Parse skills from form body
273
+ * Skills come as skills[categoryName] = "skill1, skill2, skill3"
274
+ */
275
+ function parseSkillsField(body) {
276
+ const skills = body.skills;
277
+ if (!skills || typeof skills !== "object") return {};
278
+
279
+ const result = {};
280
+ for (const [category, items] of Object.entries(skills)) {
281
+ if (typeof items === "string") {
282
+ result[category] = parseCommaList(items);
283
+ } else if (Array.isArray(items)) {
284
+ result[category] = items;
285
+ }
286
+ }
287
+ return result;
288
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * CV data storage
3
+ * Single MongoDB document with five collections:
4
+ * experience, projects, skills, education, languages, interests
5
+ * @module storage/cv
6
+ */
7
+
8
+ /**
9
+ * Get collection reference
10
+ * @param {object} application - Application instance
11
+ * @returns {Collection} MongoDB collection
12
+ */
13
+ function getCollection(application) {
14
+ const db = application.getCvDb();
15
+ return db.collection("cvData");
16
+ }
17
+
18
+ /**
19
+ * Get the full CV data
20
+ * @param {object} application - Application instance
21
+ * @returns {Promise<object|null>} CV data or null
22
+ */
23
+ export async function getCvData(application) {
24
+ const collection = getCollection(application);
25
+ return collection.findOne({ _id: "cv" });
26
+ }
27
+
28
+ /**
29
+ * Save full CV data
30
+ * @param {object} application - Application instance
31
+ * @param {object} data - CV data object
32
+ * @returns {Promise<object>} Saved document
33
+ */
34
+ export async function saveCvData(application, data) {
35
+ const collection = getCollection(application);
36
+ const now = new Date();
37
+
38
+ const document = {
39
+ _id: "cv",
40
+ experience: data.experience || [],
41
+ projects: data.projects || [],
42
+ skills: data.skills || {},
43
+ education: data.education || [],
44
+ languages: data.languages || [],
45
+ interests: data.interests || [],
46
+ lastUpdated: now,
47
+ };
48
+
49
+ await collection.replaceOne({ _id: "cv" }, document, { upsert: true });
50
+ return document;
51
+ }
52
+
53
+ /**
54
+ * Get default empty CV data
55
+ * @returns {object} Empty CV structure
56
+ */
57
+ export function getDefaultCvData() {
58
+ return {
59
+ experience: [],
60
+ projects: [],
61
+ skills: {},
62
+ education: [],
63
+ languages: [],
64
+ interests: [],
65
+ lastUpdated: null,
66
+ };
67
+ }
68
+
69
+ // --- Section-level helpers ---
70
+
71
+ /**
72
+ * Add an item to a CV array section
73
+ * @param {object} application - Application instance
74
+ * @param {string} section - Section name (experience, projects, education, languages)
75
+ * @param {object} item - Item to add
76
+ */
77
+ export async function addToSection(application, section, item) {
78
+ const data = (await getCvData(application)) || getDefaultCvData();
79
+ if (!Array.isArray(data[section])) {
80
+ data[section] = [];
81
+ }
82
+ data[section].push(item);
83
+ return saveCvData(application, data);
84
+ }
85
+
86
+ /**
87
+ * Remove an item from a CV array section by index
88
+ * @param {object} application - Application instance
89
+ * @param {string} section - Section name
90
+ * @param {number} index - Index to remove
91
+ */
92
+ export async function removeFromSection(application, section, index) {
93
+ const data = (await getCvData(application)) || getDefaultCvData();
94
+ if (Array.isArray(data[section]) && index >= 0 && index < data[section].length) {
95
+ data[section].splice(index, 1);
96
+ }
97
+ return saveCvData(application, data);
98
+ }
99
+
100
+ /**
101
+ * Add a skill category
102
+ * @param {object} application - Application instance
103
+ * @param {string} category - Category name
104
+ * @param {string[]} items - Skills in this category
105
+ */
106
+ export async function addSkillCategory(application, category, items) {
107
+ const data = (await getCvData(application)) || getDefaultCvData();
108
+ if (typeof data.skills !== "object" || Array.isArray(data.skills)) {
109
+ data.skills = {};
110
+ }
111
+ data.skills[category] = items;
112
+ return saveCvData(application, data);
113
+ }
114
+
115
+ /**
116
+ * Remove a skill category
117
+ * @param {object} application - Application instance
118
+ * @param {string} category - Category name to remove
119
+ */
120
+ export async function removeSkillCategory(application, category) {
121
+ const data = (await getCvData(application)) || getDefaultCvData();
122
+ if (data.skills && data.skills[category]) {
123
+ delete data.skills[category];
124
+ }
125
+ return saveCvData(application, data);
126
+ }
@@ -0,0 +1,85 @@
1
+ {
2
+ "cv": {
3
+ "title": "CV Editor",
4
+ "description": "Manage your CV/resume data. This data can be displayed on your homepage via the Homepage Builder plugin.",
5
+ "saved": "CV data saved successfully.",
6
+ "save": "Save Changes",
7
+ "lastUpdated": "Last updated",
8
+ "noData": "No data yet. Start adding entries below.",
9
+ "experience": {
10
+ "title": "Experience",
11
+ "description": "Work history and professional roles.",
12
+ "add": "Add Experience",
13
+ "jobTitle": "Job Title",
14
+ "company": "Company",
15
+ "location": "Location",
16
+ "startDate": "Start Date",
17
+ "endDate": "End Date",
18
+ "current": "Current position",
19
+ "type": "Type",
20
+ "typeOptions": {
21
+ "full-time": "Full-time",
22
+ "part-time": "Part-time",
23
+ "contract": "Contract",
24
+ "freelance": "Freelance",
25
+ "volunteer": "Volunteer",
26
+ "internship": "Internship"
27
+ },
28
+ "descriptionField": "Description",
29
+ "highlights": "Highlights (one per line)"
30
+ },
31
+ "projects": {
32
+ "title": "Projects",
33
+ "description": "Personal and professional projects.",
34
+ "add": "Add Project",
35
+ "name": "Name",
36
+ "url": "URL",
37
+ "descriptionField": "Description",
38
+ "technologies": "Technologies (comma-separated)",
39
+ "status": "Status",
40
+ "statusOptions": {
41
+ "active": "Active",
42
+ "maintained": "Maintained",
43
+ "archived": "Archived",
44
+ "completed": "Completed"
45
+ }
46
+ },
47
+ "skills": {
48
+ "title": "Skills",
49
+ "description": "Technical and professional skills grouped by category.",
50
+ "add": "Add Category",
51
+ "category": "Category Name",
52
+ "items": "Skills (comma-separated)"
53
+ },
54
+ "education": {
55
+ "title": "Education",
56
+ "description": "Academic background and certifications.",
57
+ "add": "Add Education",
58
+ "degree": "Degree / Certificate",
59
+ "institution": "Institution",
60
+ "location": "Location",
61
+ "year": "Year(s)",
62
+ "descriptionField": "Description"
63
+ },
64
+ "languages": {
65
+ "title": "Languages",
66
+ "description": "Languages you speak.",
67
+ "add": "Add Language",
68
+ "name": "Language",
69
+ "level": "Level",
70
+ "levelOptions": {
71
+ "native": "Native",
72
+ "fluent": "Fluent",
73
+ "advanced": "Advanced",
74
+ "intermediate": "Intermediate",
75
+ "basic": "Basic"
76
+ }
77
+ },
78
+ "interests": {
79
+ "title": "Interests",
80
+ "description": "Topics and hobbies you're passionate about.",
81
+ "add": "Add Interest",
82
+ "placeholder": "Comma-separated interests"
83
+ }
84
+ }
85
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@rmdes/indiekit-endpoint-cv",
3
+ "version": "1.0.0",
4
+ "description": "CV/Resume editor endpoint for Indiekit. Manage work experience, projects, skills, education, and interests from the admin UI.",
5
+ "keywords": [
6
+ "indiekit",
7
+ "indiekit-plugin",
8
+ "indieweb",
9
+ "cv",
10
+ "resume",
11
+ "portfolio"
12
+ ],
13
+ "homepage": "https://github.com/rmdes/indiekit-endpoint-cv",
14
+ "bugs": {
15
+ "url": "https://github.com/rmdes/indiekit-endpoint-cv/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/rmdes/indiekit-endpoint-cv.git"
20
+ },
21
+ "author": {
22
+ "name": "Ricardo Mendes",
23
+ "url": "https://rmendes.net"
24
+ },
25
+ "license": "MIT",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "type": "module",
30
+ "main": "index.js",
31
+ "exports": {
32
+ ".": "./index.js"
33
+ },
34
+ "files": [
35
+ "lib",
36
+ "locales",
37
+ "views",
38
+ "index.js"
39
+ ],
40
+ "dependencies": {
41
+ "@indiekit/error": "^1.0.0-beta.25",
42
+ "@indiekit/frontend": "^1.0.0-beta.25",
43
+ "express": "^5.0.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@indiekit/indiekit": ">=1.0.0-beta.25"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,566 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block content %}
4
+ <style>
5
+ .cv-dashboard {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--space-l, 1.5rem);
9
+ }
10
+
11
+ .cv-accordion {
12
+ background: var(--color-offset, #f5f5f5);
13
+ border-radius: var(--border-radius-small, 0.5rem);
14
+ overflow: hidden;
15
+ }
16
+
17
+ .cv-accordion__header {
18
+ display: flex;
19
+ justify-content: space-between;
20
+ align-items: center;
21
+ padding: var(--space-m, 1rem) var(--space-m, 1.5rem);
22
+ cursor: pointer;
23
+ user-select: none;
24
+ background: none;
25
+ border: none;
26
+ width: 100%;
27
+ text-align: left;
28
+ font: var(--font-heading, bold 1.125rem/1.4 sans-serif);
29
+ color: var(--color-on-surface, inherit);
30
+ }
31
+
32
+ .cv-accordion__header:hover {
33
+ background: var(--color-primary-container, #e6f0ff);
34
+ }
35
+
36
+ .cv-accordion__chevron {
37
+ transition: transform 0.2s;
38
+ width: 20px;
39
+ height: 20px;
40
+ }
41
+
42
+ .cv-accordion[open] .cv-accordion__chevron {
43
+ transform: rotate(180deg);
44
+ }
45
+
46
+ .cv-accordion__body {
47
+ padding: 0 var(--space-m, 1.5rem) var(--space-m, 1.5rem);
48
+ }
49
+
50
+ .cv-accordion__desc {
51
+ color: var(--color-on-offset, #666);
52
+ font: var(--font-body, 0.875rem/1.5 sans-serif);
53
+ margin-block-end: var(--space-s, 0.75rem);
54
+ }
55
+
56
+ .cv-item {
57
+ background: var(--color-background, #fff);
58
+ border: 1px solid var(--color-outline-variant, #ddd);
59
+ border-radius: var(--border-radius-small, 0.25rem);
60
+ padding: var(--space-s, 0.75rem);
61
+ margin-block-end: var(--space-xs, 0.5rem);
62
+ display: flex;
63
+ justify-content: space-between;
64
+ align-items: flex-start;
65
+ gap: var(--space-s, 0.75rem);
66
+ }
67
+
68
+ .cv-item__info {
69
+ flex: 1;
70
+ min-width: 0;
71
+ }
72
+
73
+ .cv-item__title {
74
+ font-weight: 600;
75
+ margin-block-end: 0.125rem;
76
+ }
77
+
78
+ .cv-item__sub {
79
+ color: var(--color-on-offset, #666);
80
+ font: var(--font-caption, 0.75rem/1.4 sans-serif);
81
+ }
82
+
83
+ .cv-item__tags {
84
+ display: flex;
85
+ flex-wrap: wrap;
86
+ gap: 0.25rem;
87
+ margin-block-start: 0.25rem;
88
+ }
89
+
90
+ .cv-tag {
91
+ display: inline-block;
92
+ background: var(--color-primary-container, #e6f0ff);
93
+ color: var(--color-on-primary-container, #003380);
94
+ font: var(--font-caption, 0.75rem/1.2 sans-serif);
95
+ padding: 0.125rem 0.5rem;
96
+ border-radius: 999px;
97
+ }
98
+
99
+ .cv-form {
100
+ background: var(--color-background, #fff);
101
+ border: 1px dashed var(--color-outline-variant, #ddd);
102
+ border-radius: var(--border-radius-small, 0.25rem);
103
+ padding: var(--space-s, 0.75rem);
104
+ margin-block-start: var(--space-s, 0.75rem);
105
+ }
106
+
107
+ .cv-form h4 {
108
+ font: var(--font-subhead, bold 0.875rem/1.4 sans-serif);
109
+ margin-block-end: var(--space-xs, 0.5rem);
110
+ }
111
+
112
+ .cv-form .field {
113
+ margin-block-end: var(--space-xs, 0.5rem);
114
+ }
115
+
116
+ .cv-form .field__label {
117
+ display: block;
118
+ font: var(--font-caption, 0.75rem/1.4 sans-serif);
119
+ font-weight: 600;
120
+ margin-block-end: 0.25rem;
121
+ }
122
+
123
+ .cv-form .field__input {
124
+ width: 100%;
125
+ padding: var(--space-2xs, 0.375rem) var(--space-xs, 0.5rem);
126
+ border: 1px solid var(--color-outline-variant, #ccc);
127
+ border-radius: var(--border-radius-small, 0.25rem);
128
+ font: var(--font-body, 0.875rem/1.5 sans-serif);
129
+ background: var(--color-background, #fff);
130
+ color: var(--color-on-surface, inherit);
131
+ }
132
+
133
+ .cv-form .field__input:focus {
134
+ border-color: var(--color-primary, #0066cc);
135
+ outline: 2px solid var(--color-primary, #0066cc);
136
+ outline-offset: -2px;
137
+ }
138
+
139
+ .cv-form select.field__input {
140
+ appearance: auto;
141
+ }
142
+
143
+ .cv-form textarea.field__input {
144
+ resize: vertical;
145
+ min-height: 4rem;
146
+ }
147
+
148
+ .cv-form .field-row {
149
+ display: grid;
150
+ grid-template-columns: 1fr 1fr;
151
+ gap: var(--space-xs, 0.5rem);
152
+ }
153
+
154
+ .cv-empty {
155
+ color: var(--color-on-offset, #999);
156
+ font: var(--font-caption, 0.875rem/1.4 sans-serif);
157
+ text-align: center;
158
+ padding: var(--space-s, 0.75rem);
159
+ }
160
+
161
+ .cv-success {
162
+ background: var(--color-success-container, #d4edda);
163
+ border: 1px solid var(--color-success, #28a745);
164
+ border-radius: var(--border-radius-small, 0.25rem);
165
+ padding: var(--space-s, 0.75rem);
166
+ margin-block-end: var(--space-m, 1rem);
167
+ }
168
+
169
+ .cv-error {
170
+ background: var(--color-error-container, #f8d7da);
171
+ border: 1px solid var(--color-error, #dc3545);
172
+ border-radius: var(--border-radius-small, 0.25rem);
173
+ padding: var(--space-s, 0.75rem);
174
+ margin-block-end: var(--space-m, 1rem);
175
+ }
176
+ </style>
177
+
178
+ <header class="page-header">
179
+ <h1 class="page-header__title">{{ __("cv.title") }}</h1>
180
+ <p class="page-header__description">{{ __("cv.description") }}</p>
181
+ </header>
182
+
183
+ {% if request.query.saved %}
184
+ <div class="cv-success">
185
+ <p>{{ __("cv.saved") }}</p>
186
+ </div>
187
+ {% endif %}
188
+
189
+ {% if request.query.error %}
190
+ <div class="cv-error">
191
+ <p>An error occurred. Please try again.</p>
192
+ </div>
193
+ {% endif %}
194
+
195
+ {% if cv.lastUpdated %}
196
+ <p class="cv-accordion__desc">{{ __("cv.lastUpdated") }}: {{ cv.lastUpdated }}</p>
197
+ {% endif %}
198
+
199
+ <div class="cv-dashboard">
200
+
201
+ {# ===== EXPERIENCE ===== #}
202
+ <details class="cv-accordion" id="experience" {% if not cv.experience.length %}open{% endif %}>
203
+ <summary class="cv-accordion__header">
204
+ {{ __("cv.experience.title") }} ({{ cv.experience.length or 0 }})
205
+ <svg class="cv-accordion__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
206
+ </summary>
207
+ <div class="cv-accordion__body">
208
+ <p class="cv-accordion__desc">{{ __("cv.experience.description") }}</p>
209
+
210
+ {% if cv.experience.length %}
211
+ {% for item in cv.experience %}
212
+ <div class="cv-item">
213
+ <div class="cv-item__info">
214
+ <div class="cv-item__title">{{ item.title }}</div>
215
+ <div class="cv-item__sub">
216
+ {{ item.company }}{% if item.location %} &middot; {{ item.location }}{% endif %}
217
+ {% if item.startDate %} &middot; {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}{% endif %}
218
+ {% if item.type %} &middot; {{ item.type }}{% endif %}
219
+ </div>
220
+ {% if item.description %}
221
+ <div class="cv-item__sub" style="margin-top:0.25rem">{{ item.description }}</div>
222
+ {% endif %}
223
+ {% if item.highlights and item.highlights.length %}
224
+ <div class="cv-item__tags">
225
+ {% for h in item.highlights %}<span class="cv-tag">{{ h }}</span>{% endfor %}
226
+ </div>
227
+ {% endif %}
228
+ </div>
229
+ <form method="post" action="{{ cvEndpoint }}/experience/{{ loop.index0 }}/delete" style="margin:0">
230
+ <button type="submit" class="button button--small button--secondary" onclick="return confirm('Delete this entry?')">Delete</button>
231
+ </form>
232
+ </div>
233
+ {% endfor %}
234
+ {% else %}
235
+ <p class="cv-empty">{{ __("cv.noData") }}</p>
236
+ {% endif %}
237
+
238
+ <div class="cv-form">
239
+ <h4>{{ __("cv.experience.add") }}</h4>
240
+ <form method="post" action="{{ cvEndpoint }}/experience/add">
241
+ <div class="field-row">
242
+ <div class="field">
243
+ <label class="field__label" for="exp-title">{{ __("cv.experience.jobTitle") }}</label>
244
+ <input class="field__input" type="text" id="exp-title" name="title" required>
245
+ </div>
246
+ <div class="field">
247
+ <label class="field__label" for="exp-company">{{ __("cv.experience.company") }}</label>
248
+ <input class="field__input" type="text" id="exp-company" name="company" required>
249
+ </div>
250
+ </div>
251
+ <div class="field-row">
252
+ <div class="field">
253
+ <label class="field__label" for="exp-location">{{ __("cv.experience.location") }}</label>
254
+ <input class="field__input" type="text" id="exp-location" name="location">
255
+ </div>
256
+ <div class="field">
257
+ <label class="field__label" for="exp-type">{{ __("cv.experience.type") }}</label>
258
+ <select class="field__input" id="exp-type" name="type">
259
+ <option value="full-time">Full-time</option>
260
+ <option value="part-time">Part-time</option>
261
+ <option value="contract">Contract</option>
262
+ <option value="freelance">Freelance</option>
263
+ <option value="volunteer">Volunteer</option>
264
+ <option value="internship">Internship</option>
265
+ </select>
266
+ </div>
267
+ </div>
268
+ <div class="field-row">
269
+ <div class="field">
270
+ <label class="field__label" for="exp-start">{{ __("cv.experience.startDate") }}</label>
271
+ <input class="field__input" type="text" id="exp-start" name="startDate" placeholder="YYYY-MM">
272
+ </div>
273
+ <div class="field">
274
+ <label class="field__label" for="exp-end">{{ __("cv.experience.endDate") }}</label>
275
+ <input class="field__input" type="text" id="exp-end" name="endDate" placeholder="YYYY-MM or leave blank">
276
+ </div>
277
+ </div>
278
+ <div class="field">
279
+ <label class="field__label" for="exp-desc">{{ __("cv.experience.descriptionField") }}</label>
280
+ <textarea class="field__input" id="exp-desc" name="description" rows="2"></textarea>
281
+ </div>
282
+ <div class="field">
283
+ <label class="field__label" for="exp-highlights">{{ __("cv.experience.highlights") }}</label>
284
+ <textarea class="field__input" id="exp-highlights" name="highlights" rows="3" placeholder="One highlight per line"></textarea>
285
+ </div>
286
+ <button type="submit" class="button button--primary button--small">{{ __("cv.experience.add") }}</button>
287
+ </form>
288
+ </div>
289
+ </div>
290
+ </details>
291
+
292
+ {# ===== PROJECTS ===== #}
293
+ <details class="cv-accordion" id="projects">
294
+ <summary class="cv-accordion__header">
295
+ {{ __("cv.projects.title") }} ({{ cv.projects.length or 0 }})
296
+ <svg class="cv-accordion__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
297
+ </summary>
298
+ <div class="cv-accordion__body">
299
+ <p class="cv-accordion__desc">{{ __("cv.projects.description") }}</p>
300
+
301
+ {% if cv.projects.length %}
302
+ {% for item in cv.projects %}
303
+ <div class="cv-item">
304
+ <div class="cv-item__info">
305
+ <div class="cv-item__title">
306
+ {% if item.url %}<a href="{{ item.url }}">{{ item.name }}</a>{% else %}{{ item.name }}{% endif %}
307
+ </div>
308
+ <div class="cv-item__sub">
309
+ {% if item.status %}<span class="cv-tag">{{ item.status }}</span>{% endif %}
310
+ {% if item.description %} {{ item.description }}{% endif %}
311
+ </div>
312
+ {% if item.technologies and item.technologies.length %}
313
+ <div class="cv-item__tags">
314
+ {% for t in item.technologies %}<span class="cv-tag">{{ t }}</span>{% endfor %}
315
+ </div>
316
+ {% endif %}
317
+ </div>
318
+ <form method="post" action="{{ cvEndpoint }}/projects/{{ loop.index0 }}/delete" style="margin:0">
319
+ <button type="submit" class="button button--small button--secondary" onclick="return confirm('Delete this entry?')">Delete</button>
320
+ </form>
321
+ </div>
322
+ {% endfor %}
323
+ {% else %}
324
+ <p class="cv-empty">{{ __("cv.noData") }}</p>
325
+ {% endif %}
326
+
327
+ <div class="cv-form">
328
+ <h4>{{ __("cv.projects.add") }}</h4>
329
+ <form method="post" action="{{ cvEndpoint }}/projects/add">
330
+ <div class="field-row">
331
+ <div class="field">
332
+ <label class="field__label" for="proj-name">{{ __("cv.projects.name") }}</label>
333
+ <input class="field__input" type="text" id="proj-name" name="name" required>
334
+ </div>
335
+ <div class="field">
336
+ <label class="field__label" for="proj-url">{{ __("cv.projects.url") }}</label>
337
+ <input class="field__input" type="url" id="proj-url" name="url" placeholder="https://...">
338
+ </div>
339
+ </div>
340
+ <div class="field">
341
+ <label class="field__label" for="proj-desc">{{ __("cv.projects.descriptionField") }}</label>
342
+ <textarea class="field__input" id="proj-desc" name="description" rows="2"></textarea>
343
+ </div>
344
+ <div class="field-row">
345
+ <div class="field">
346
+ <label class="field__label" for="proj-tech">{{ __("cv.projects.technologies") }}</label>
347
+ <input class="field__input" type="text" id="proj-tech" name="technologies" placeholder="Docker, Node.js, Python">
348
+ </div>
349
+ <div class="field">
350
+ <label class="field__label" for="proj-status">{{ __("cv.projects.status") }}</label>
351
+ <select class="field__input" id="proj-status" name="status">
352
+ <option value="active">Active</option>
353
+ <option value="maintained">Maintained</option>
354
+ <option value="archived">Archived</option>
355
+ <option value="completed">Completed</option>
356
+ </select>
357
+ </div>
358
+ </div>
359
+ <button type="submit" class="button button--primary button--small">{{ __("cv.projects.add") }}</button>
360
+ </form>
361
+ </div>
362
+ </div>
363
+ </details>
364
+
365
+ {# ===== SKILLS ===== #}
366
+ <details class="cv-accordion" id="skills">
367
+ <summary class="cv-accordion__header">
368
+ {{ __("cv.skills.title") }} ({{ cv.skills | dictsort | length if cv.skills else 0 }})
369
+ <svg class="cv-accordion__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
370
+ </summary>
371
+ <div class="cv-accordion__body">
372
+ <p class="cv-accordion__desc">{{ __("cv.skills.description") }}</p>
373
+
374
+ {% if cv.skills and (cv.skills | dictsort | length) %}
375
+ {% for category, items in cv.skills | dictsort %}
376
+ <div class="cv-item">
377
+ <div class="cv-item__info">
378
+ <div class="cv-item__title">{{ category }}</div>
379
+ <div class="cv-item__tags">
380
+ {% for skill in items %}<span class="cv-tag">{{ skill }}</span>{% endfor %}
381
+ </div>
382
+ </div>
383
+ <form method="post" action="{{ cvEndpoint }}/skills/{{ category | urlencode }}/delete" style="margin:0">
384
+ <button type="submit" class="button button--small button--secondary" onclick="return confirm('Delete this category?')">Delete</button>
385
+ </form>
386
+ </div>
387
+ {% endfor %}
388
+ {% else %}
389
+ <p class="cv-empty">{{ __("cv.noData") }}</p>
390
+ {% endif %}
391
+
392
+ <div class="cv-form">
393
+ <h4>{{ __("cv.skills.add") }}</h4>
394
+ <form method="post" action="{{ cvEndpoint }}/skills/add">
395
+ <div class="field-row">
396
+ <div class="field">
397
+ <label class="field__label" for="skill-cat">{{ __("cv.skills.category") }}</label>
398
+ <input class="field__input" type="text" id="skill-cat" name="category" required placeholder="e.g. Programming Languages">
399
+ </div>
400
+ <div class="field">
401
+ <label class="field__label" for="skill-items">{{ __("cv.skills.items") }}</label>
402
+ <input class="field__input" type="text" id="skill-items" name="items" required placeholder="Python, JavaScript, Go">
403
+ </div>
404
+ </div>
405
+ <button type="submit" class="button button--primary button--small">{{ __("cv.skills.add") }}</button>
406
+ </form>
407
+ </div>
408
+ </div>
409
+ </details>
410
+
411
+ {# ===== EDUCATION ===== #}
412
+ <details class="cv-accordion" id="education">
413
+ <summary class="cv-accordion__header">
414
+ {{ __("cv.education.title") }} ({{ cv.education.length or 0 }})
415
+ <svg class="cv-accordion__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
416
+ </summary>
417
+ <div class="cv-accordion__body">
418
+ <p class="cv-accordion__desc">{{ __("cv.education.description") }}</p>
419
+
420
+ {% if cv.education.length %}
421
+ {% for item in cv.education %}
422
+ <div class="cv-item">
423
+ <div class="cv-item__info">
424
+ <div class="cv-item__title">{{ item.degree }}</div>
425
+ <div class="cv-item__sub">
426
+ {{ item.institution }}{% if item.location %} &middot; {{ item.location }}{% endif %}{% if item.year %} &middot; {{ item.year }}{% endif %}
427
+ </div>
428
+ {% if item.description %}
429
+ <div class="cv-item__sub" style="margin-top:0.25rem">{{ item.description }}</div>
430
+ {% endif %}
431
+ </div>
432
+ <form method="post" action="{{ cvEndpoint }}/education/{{ loop.index0 }}/delete" style="margin:0">
433
+ <button type="submit" class="button button--small button--secondary" onclick="return confirm('Delete this entry?')">Delete</button>
434
+ </form>
435
+ </div>
436
+ {% endfor %}
437
+ {% else %}
438
+ <p class="cv-empty">{{ __("cv.noData") }}</p>
439
+ {% endif %}
440
+
441
+ <div class="cv-form">
442
+ <h4>{{ __("cv.education.add") }}</h4>
443
+ <form method="post" action="{{ cvEndpoint }}/education/add">
444
+ <div class="field-row">
445
+ <div class="field">
446
+ <label class="field__label" for="edu-degree">{{ __("cv.education.degree") }}</label>
447
+ <input class="field__input" type="text" id="edu-degree" name="degree" required>
448
+ </div>
449
+ <div class="field">
450
+ <label class="field__label" for="edu-inst">{{ __("cv.education.institution") }}</label>
451
+ <input class="field__input" type="text" id="edu-inst" name="institution" required>
452
+ </div>
453
+ </div>
454
+ <div class="field-row">
455
+ <div class="field">
456
+ <label class="field__label" for="edu-location">{{ __("cv.education.location") }}</label>
457
+ <input class="field__input" type="text" id="edu-location" name="location">
458
+ </div>
459
+ <div class="field">
460
+ <label class="field__label" for="edu-year">{{ __("cv.education.year") }}</label>
461
+ <input class="field__input" type="text" id="edu-year" name="year" placeholder="2020-2024">
462
+ </div>
463
+ </div>
464
+ <div class="field">
465
+ <label class="field__label" for="edu-desc">{{ __("cv.education.descriptionField") }}</label>
466
+ <textarea class="field__input" id="edu-desc" name="description" rows="2"></textarea>
467
+ </div>
468
+ <button type="submit" class="button button--primary button--small">{{ __("cv.education.add") }}</button>
469
+ </form>
470
+ </div>
471
+ </div>
472
+ </details>
473
+
474
+ {# ===== LANGUAGES ===== #}
475
+ <details class="cv-accordion" id="languages">
476
+ <summary class="cv-accordion__header">
477
+ {{ __("cv.languages.title") }} ({{ cv.languages.length or 0 }})
478
+ <svg class="cv-accordion__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
479
+ </summary>
480
+ <div class="cv-accordion__body">
481
+ <p class="cv-accordion__desc">{{ __("cv.languages.description") }}</p>
482
+
483
+ {% if cv.languages.length %}
484
+ {% for item in cv.languages %}
485
+ <div class="cv-item">
486
+ <div class="cv-item__info">
487
+ <div class="cv-item__title">{{ item.name }}</div>
488
+ <div class="cv-item__sub">{{ item.level }}</div>
489
+ </div>
490
+ <form method="post" action="{{ cvEndpoint }}/languages/{{ loop.index0 }}/delete" style="margin:0">
491
+ <button type="submit" class="button button--small button--secondary" onclick="return confirm('Delete this entry?')">Delete</button>
492
+ </form>
493
+ </div>
494
+ {% endfor %}
495
+ {% else %}
496
+ <p class="cv-empty">{{ __("cv.noData") }}</p>
497
+ {% endif %}
498
+
499
+ <div class="cv-form">
500
+ <h4>{{ __("cv.languages.add") }}</h4>
501
+ <form method="post" action="{{ cvEndpoint }}/languages/add">
502
+ <div class="field-row">
503
+ <div class="field">
504
+ <label class="field__label" for="lang-name">{{ __("cv.languages.name") }}</label>
505
+ <input class="field__input" type="text" id="lang-name" name="name" required>
506
+ </div>
507
+ <div class="field">
508
+ <label class="field__label" for="lang-level">{{ __("cv.languages.level") }}</label>
509
+ <select class="field__input" id="lang-level" name="level">
510
+ <option value="native">Native</option>
511
+ <option value="fluent">Fluent</option>
512
+ <option value="advanced">Advanced</option>
513
+ <option value="intermediate" selected>Intermediate</option>
514
+ <option value="basic">Basic</option>
515
+ </select>
516
+ </div>
517
+ </div>
518
+ <button type="submit" class="button button--primary button--small">{{ __("cv.languages.add") }}</button>
519
+ </form>
520
+ </div>
521
+ </div>
522
+ </details>
523
+
524
+ {# ===== INTERESTS ===== #}
525
+ <details class="cv-accordion" id="interests">
526
+ <summary class="cv-accordion__header">
527
+ {{ __("cv.interests.title") }} ({{ cv.interests.length or 0 }})
528
+ <svg class="cv-accordion__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
529
+ </summary>
530
+ <div class="cv-accordion__body">
531
+ <p class="cv-accordion__desc">{{ __("cv.interests.description") }}</p>
532
+
533
+ {% if cv.interests.length %}
534
+ <div class="cv-item">
535
+ <div class="cv-item__info">
536
+ <div class="cv-item__tags">
537
+ {% for interest in cv.interests %}<span class="cv-tag">{{ interest }}</span>{% endfor %}
538
+ </div>
539
+ </div>
540
+ </div>
541
+ {% endif %}
542
+
543
+ <div class="cv-form">
544
+ <h4>{{ __("cv.interests.add") }}</h4>
545
+ <form method="post" action="{{ cvEndpoint }}/save">
546
+ {# Pass through all existing data to preserve it #}
547
+ <input type="hidden" name="experience" value='{{ cv.experience | dump | safe }}'>
548
+ <input type="hidden" name="projects" value='{{ cv.projects | dump | safe }}'>
549
+ <input type="hidden" name="education" value='{{ cv.education | dump | safe }}'>
550
+ <input type="hidden" name="languages" value='{{ cv.languages | dump | safe }}'>
551
+ {# Skills need special handling - pass as JSON #}
552
+ {% for category, items in cv.skills | dictsort %}
553
+ <input type="hidden" name="skills[{{ category }}]" value="{{ items | join(', ') }}">
554
+ {% endfor %}
555
+ <div class="field">
556
+ <label class="field__label" for="interests-input">{{ __("cv.interests.placeholder") }}</label>
557
+ <input class="field__input" type="text" id="interests-input" name="interests" value="{{ cv.interests | join(', ') }}" placeholder="Open Source, IndieWeb, Music">
558
+ </div>
559
+ <button type="submit" class="button button--primary button--small">{{ __("cv.save") }}</button>
560
+ </form>
561
+ </div>
562
+ </div>
563
+ </details>
564
+
565
+ </div>
566
+ {% endblock %}