@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 +198 -0
- package/lib/controllers/api.js +27 -0
- package/lib/controllers/dashboard.js +288 -0
- package/lib/storage/cv.js +126 -0
- package/locales/en.json +85 -0
- package/package.json +51 -0
- package/views/cv-dashboard.njk +566 -0
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
|
+
}
|
package/locales/en.json
ADDED
|
@@ -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 %} · {{ item.location }}{% endif %}
|
|
217
|
+
{% if item.startDate %} · {{ item.startDate }}{% if item.endDate %} – {{ item.endDate }}{% else %} – Present{% endif %}{% endif %}
|
|
218
|
+
{% if item.type %} · {{ 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 %} · {{ item.location }}{% endif %}{% if item.year %} · {{ 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 %}
|