Files
poyraz-portfolio/apps/data-panel/renderer/app.js
T

630 lines
22 KiB
JavaScript

import { BLOG_CATEGORIES, COLLECTION_CONFIGS } from "./configs.js";
const state = {
activeTab: "blog",
folders: [],
selectedFolder: "",
blogPosts: [],
selectedBlogSlug: "",
blogOriginalSlug: "",
collection: {
key: "announcement",
items: [],
selectedIndex: null,
editingIndex: null,
formInputs: {},
},
};
const el = {
tabs: document.querySelectorAll(".tab-btn"),
panels: {
blog: document.getElementById("tab-blog"),
collection: document.getElementById("tab-collection"),
media: document.getElementById("tab-media"),
publish: document.getElementById("tab-publish"),
},
blogCardList: document.getElementById("blog-card-list"),
refreshBlogFiles: document.getElementById("refresh-blog-files"),
newBlogFile: document.getElementById("new-blog-file"),
deleteBlogFile: document.getElementById("delete-blog-file"),
blogEditorTitle: document.getElementById("blog-editor-title"),
blogSlug: document.getElementById("blog-slug"),
blogTitle: document.getElementById("blog-title"),
blogCategory: document.getElementById("blog-category"),
blogDate: document.getElementById("blog-date"),
blogReadTime: document.getElementById("blog-read-time"),
blogAuthor: document.getElementById("blog-author"),
blogCoverImage: document.getElementById("blog-cover-image"),
blogExcerpt: document.getElementById("blog-excerpt"),
blogEditor: document.getElementById("blog-editor"),
saveBlogFile: document.getElementById("save-blog-file"),
collectionSidebarTitle: document.getElementById("collection-sidebar-title"),
collectionCardList: document.getElementById("collection-card-list"),
refreshCollection: document.getElementById("refresh-collection"),
newCollectionItem: document.getElementById("new-collection-item"),
deleteCollectionItem: document.getElementById("delete-collection-item"),
saveCollectionItem: document.getElementById("save-collection-item"),
collectionEditorTitle: document.getElementById("collection-editor-title"),
collectionHelper: document.getElementById("collection-helper"),
collectionFormGrid: document.getElementById("collection-form-grid"),
folderSelect: document.getElementById("folder-select"),
newFolder: document.getElementById("new-folder"),
createFolder: document.getElementById("create-folder"),
uploadFile: document.getElementById("upload-file"),
refreshMedia: document.getElementById("refresh-media"),
mediaFiles: document.getElementById("media-files"),
refreshStatus: document.getElementById("refresh-status"),
commitMessage: document.getElementById("commit-message"),
publishBtn: document.getElementById("publish-btn"),
gitStatus: document.getElementById("git-status"),
publishLog: document.getElementById("publish-log"),
};
function notify(message) {
el.publishLog.textContent = message;
}
function getCollectionConfig(key = state.collection.key) {
const config = COLLECTION_CONFIGS[key];
if (!config) throw new Error(`Unknown collection config: ${key}`);
return config;
}
function setActiveTab(tab, options = {}) {
state.activeTab = tab;
if (tab === "collection" && options.collectionKey) state.collection.key = options.collectionKey;
for (const button of el.tabs) {
const buttonTab = button.dataset.tab;
const isCollection = buttonTab === "collection";
const isActive = isCollection
? tab === "collection" && button.dataset.collectionKey === state.collection.key
: buttonTab === tab;
button.classList.toggle("active", isActive);
}
for (const [key, panel] of Object.entries(el.panels)) {
panel.classList.toggle("active", key === tab);
}
if (tab === "collection") {
void loadCollection(state.collection.key).catch((error) => notify(String(error.message || error)));
}
}
function emptyBlogDraft() {
return {
slug: "",
title: "",
category: "General",
date: "",
readTime: "",
author: "Poyraz Avsever",
excerpt: "",
coverImage: "/news/design.svg",
markdown: "",
};
}
function ensureBlogCategorySelect(selectedCategory = "General") {
const options = BLOG_CATEGORIES;
const normalizedSelected = String(selectedCategory || "").trim();
const hasSelected = options.includes(normalizedSelected);
const finalValue = hasSelected ? normalizedSelected : "General";
el.blogCategory.innerHTML = "";
for (const category of options) {
const option = document.createElement("option");
option.value = category;
option.textContent = category;
el.blogCategory.appendChild(option);
}
el.blogCategory.value = finalValue;
}
function currentBlogDraft() {
return {
slug: el.blogSlug.value.trim(),
title: el.blogTitle.value.trim(),
category: el.blogCategory.value.trim(),
date: el.blogDate.value.trim(),
readTime: el.blogReadTime.value.trim(),
author: el.blogAuthor.value.trim(),
excerpt: el.blogExcerpt.value.trim(),
coverImage: el.blogCoverImage.value.trim(),
markdown: el.blogEditor.value,
};
}
function fillBlogForm(post) {
const draft = post || emptyBlogDraft();
el.blogSlug.value = draft.slug || "";
el.blogTitle.value = draft.title || "";
ensureBlogCategorySelect(draft.category || "General");
el.blogDate.value = draft.date || "";
el.blogReadTime.value = draft.readTime || "";
el.blogAuthor.value = draft.author || "Poyraz Avsever";
el.blogExcerpt.value = draft.excerpt || "";
el.blogCoverImage.value = draft.coverImage || "/news/design.svg";
el.blogEditor.value = draft.markdown || "";
}
function renderPostCards(root, items, selectedSlug, onEdit, onDelete) {
root.innerHTML = "";
for (const item of items) {
const card = document.createElement("article");
card.className = `blog-card ${selectedSlug === item.slug ? "active" : ""}`;
const title = document.createElement("div");
title.className = "title";
title.textContent = item.title || item.slug;
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = item.category ? `${item.category} - ${item.date || "-"}` : item.date || "-";
const slug = document.createElement("div");
slug.className = "meta";
slug.textContent = item.slug;
const row = document.createElement("div");
row.className = "row";
const editBtn = document.createElement("button");
editBtn.className = "btn btn-ghost";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => onEdit(item.slug));
const deleteBtn = document.createElement("button");
deleteBtn.className = "btn btn-danger";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", () => onDelete(item.slug));
row.append(editBtn, deleteBtn);
card.append(title, meta, slug, row);
root.appendChild(card);
}
}
async function loadBlogFiles() {
state.blogPosts = await window.panelAPI.blog.list();
renderPostCards(el.blogCardList, state.blogPosts, state.selectedBlogSlug, selectBlog, (slug) => void removeBlogFile(slug));
if (!state.selectedBlogSlug && state.blogPosts.length) selectBlog(state.blogPosts[0].slug);
}
function selectBlog(slug) {
const post = state.blogPosts.find((item) => item.slug === slug);
if (!post) return;
state.selectedBlogSlug = slug;
state.blogOriginalSlug = slug;
el.blogEditorTitle.textContent = `Edit Blog: ${slug}`;
fillBlogForm(post);
renderPostCards(el.blogCardList, state.blogPosts, state.selectedBlogSlug, selectBlog, (value) => void removeBlogFile(value));
}
function createBlogFile() {
state.selectedBlogSlug = "";
state.blogOriginalSlug = "";
el.blogEditorTitle.textContent = "Create Blog Post";
fillBlogForm(emptyBlogDraft());
renderPostCards(el.blogCardList, state.blogPosts, state.selectedBlogSlug, selectBlog, (slug) => void removeBlogFile(slug));
}
async function saveBlogFile() {
const post = currentBlogDraft();
if (!post.slug || !post.title) {
notify("Slug and title are required.");
return;
}
const result = await window.panelAPI.blog.upsert({
originalSlug: state.blogOriginalSlug || undefined,
post,
});
await loadBlogFiles();
state.selectedBlogSlug = result.slug;
state.blogOriginalSlug = result.slug;
selectBlog(result.slug);
notify(`Saved blog post: ${result.slug}`);
}
async function removeBlogFile(slugArg) {
const targetSlug = slugArg || state.selectedBlogSlug;
if (!targetSlug) return;
if (!window.confirm(`Delete blog post ${targetSlug}?`)) return;
await window.panelAPI.blog.delete(targetSlug);
state.selectedBlogSlug = "";
state.blogOriginalSlug = "";
createBlogFile();
await loadBlogFiles();
notify("Blog post deleted.");
}
function deserializeCollectionItem(config, rawItem) {
if (typeof config.deserializeItem === "function") return config.deserializeItem(rawItem);
if (rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)) return { ...rawItem };
return {};
}
function serializeCollectionItem(config, item) {
if (typeof config.serializeItem === "function") return config.serializeItem(item);
return { ...item };
}
function emptyCollectionItem(config) {
const base = {};
for (const field of config.fields) {
if (field.type !== "number") base[field.key] = field.defaultValue || "";
}
return base;
}
function renderCollectionHeader(config) {
el.collectionSidebarTitle.textContent = config.label;
el.collectionHelper.textContent = `${config.fileName} -> ${config.exportName}`;
el.newCollectionItem.textContent = `New ${config.itemLabel}`;
}
function renderCollectionForm(config) {
state.collection.formInputs = {};
el.collectionFormGrid.innerHTML = "";
for (const field of config.fields) {
const wrapper = document.createElement("div");
if (field.full) wrapper.className = "full";
const label = document.createElement("label");
const fieldId = `collection-${state.collection.key}-${field.key}`;
label.setAttribute("for", fieldId);
label.textContent = field.label;
let input;
if (field.type === "textarea") {
input = document.createElement("textarea");
input.className = "small-editor";
input.spellcheck = false;
} else {
input = document.createElement("input");
input.type = field.type === "number" ? "number" : field.type === "url" ? "url" : "text";
}
input.id = fieldId;
if (field.placeholder) input.placeholder = field.placeholder;
if (field.required) input.required = true;
if (field.type === "number") {
if (typeof field.min === "number") input.min = String(field.min);
if (typeof field.max === "number") input.max = String(field.max);
if (typeof field.step === "number") input.step = String(field.step);
}
state.collection.formInputs[field.key] = input;
wrapper.append(label, input);
el.collectionFormGrid.appendChild(wrapper);
}
}
function fillCollectionForm(config, item) {
const source = item || emptyCollectionItem(config);
for (const field of config.fields) {
const input = state.collection.formInputs[field.key];
if (!input) continue;
const value = source[field.key];
input.value = value === undefined || value === null ? "" : String(value);
}
}
function readCollectionForm(config) {
const values = {};
for (const field of config.fields) {
const input = state.collection.formInputs[field.key];
if (!input) continue;
if (field.type === "number") {
const value = input.value.trim();
if (!value) {
if (field.required) throw new Error(`${field.label} is required.`);
continue;
}
const parsed = Number(value);
if (Number.isNaN(parsed)) throw new Error(`${field.label} must be a number.`);
values[field.key] = parsed;
continue;
}
const value = field.type === "textarea" ? input.value.trim() : input.value.trim();
if (!value) {
if (field.required) throw new Error(`${field.label} is required.`);
continue;
}
values[field.key] = value;
}
return values;
}
function renderCollectionCards(config) {
el.collectionCardList.innerHTML = "";
state.collection.items.forEach((item, index) => {
const summary = typeof config.card === "function"
? config.card(item, index)
: { title: item.title || item.id || `Item ${index + 1}`, meta: [] };
const card = document.createElement("article");
card.className = `blog-card ${state.collection.selectedIndex === index ? "active" : ""}`;
const title = document.createElement("div");
title.className = "title";
title.textContent = summary.title || `Item ${index + 1}`;
card.appendChild(title);
for (const metaText of summary.meta || []) {
if (!metaText) continue;
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = String(metaText);
card.appendChild(meta);
}
if (summary.footer) {
const footer = document.createElement("div");
footer.className = "meta";
footer.textContent = String(summary.footer);
card.appendChild(footer);
}
const row = document.createElement("div");
row.className = "row";
const editBtn = document.createElement("button");
editBtn.className = "btn btn-ghost";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => selectCollectionItem(index));
const deleteBtn = document.createElement("button");
deleteBtn.className = "btn btn-danger";
deleteBtn.textContent = "Delete";
deleteBtn.addEventListener("click", () => void removeCollectionItem(index));
row.append(editBtn, deleteBtn);
card.appendChild(row);
el.collectionCardList.appendChild(card);
});
}
function startCollectionCreateMode(config) {
state.collection.selectedIndex = null;
state.collection.editingIndex = null;
el.collectionEditorTitle.textContent = `Create ${config.itemLabel}`;
fillCollectionForm(config, emptyCollectionItem(config));
renderCollectionCards(config);
}
function selectCollectionItem(index) {
const config = getCollectionConfig();
const item = state.collection.items[index];
if (!item) return;
state.collection.selectedIndex = index;
state.collection.editingIndex = index;
el.collectionEditorTitle.textContent = `Edit ${config.itemLabel} #${index + 1}`;
fillCollectionForm(config, item);
renderCollectionCards(config);
}
async function loadCollection(key = state.collection.key, options = {}) {
const config = getCollectionConfig(key);
const file = await window.panelAPI.data.read(config.fileName);
const targetExport = file.exports.find((entry) => entry.name === config.exportName);
if (!targetExport) throw new Error(`Export ${config.exportName} not found in ${config.fileName}.`);
if (targetExport.parseError) throw new Error(targetExport.parseError);
if (!Array.isArray(targetExport.value)) throw new Error(`Export ${config.exportName} must be array.`);
state.collection.key = key;
state.collection.items = targetExport.value.map((item) => deserializeCollectionItem(config, item));
renderCollectionHeader(config);
renderCollectionForm(config);
let selectedIndex = null;
if (typeof options.selectIndex === "number") {
selectedIndex = options.selectIndex;
} else if (options.keepSelection && typeof state.collection.selectedIndex === "number") {
selectedIndex = state.collection.selectedIndex;
}
if (typeof selectedIndex === "number" && selectedIndex >= 0 && selectedIndex < state.collection.items.length) {
state.collection.selectedIndex = selectedIndex;
state.collection.editingIndex = selectedIndex;
el.collectionEditorTitle.textContent = `Edit ${config.itemLabel} #${selectedIndex + 1}`;
fillCollectionForm(config, state.collection.items[selectedIndex]);
renderCollectionCards(config);
} else {
startCollectionCreateMode(config);
}
}
async function saveCollectionItem() {
const config = getCollectionConfig();
const draft = readCollectionForm(config);
const next = [...state.collection.items];
let selectedIndex;
if (typeof state.collection.editingIndex === "number") {
selectedIndex = state.collection.editingIndex;
next[selectedIndex] = draft;
} else {
next.push(draft);
selectedIndex = next.length - 1;
}
await window.panelAPI.data.updateExport({
fileName: config.fileName,
exportName: config.exportName,
value: next.map((item) => serializeCollectionItem(config, item)),
});
await loadCollection(state.collection.key, { selectIndex: selectedIndex });
notify(`Saved ${config.itemLabel.toLowerCase()} in ${config.label}.`);
}
async function removeCollectionItem(indexArg) {
const config = getCollectionConfig();
const targetIndex = typeof indexArg === "number" ? indexArg : state.collection.selectedIndex;
if (typeof targetIndex !== "number" || !state.collection.items[targetIndex]) return;
if (!window.confirm(`Delete ${config.itemLabel} #${targetIndex + 1}?`)) return;
const next = state.collection.items.filter((_, index) => index !== targetIndex);
await window.panelAPI.data.updateExport({
fileName: config.fileName,
exportName: config.exportName,
value: next.map((item) => serializeCollectionItem(config, item)),
});
const selectedIndex = next.length ? Math.max(0, targetIndex - 1) : undefined;
await loadCollection(state.collection.key, { selectIndex: selectedIndex });
notify(`${config.itemLabel} deleted from ${config.label}.`);
}
async function loadMediaFolders() {
state.folders = await window.panelAPI.media.listFolders();
el.folderSelect.innerHTML = "";
for (const folder of state.folders) {
const option = document.createElement("option");
option.value = folder;
option.textContent = folder || "/";
el.folderSelect.appendChild(option);
}
if (!state.selectedFolder || !state.folders.includes(state.selectedFolder)) {
state.selectedFolder = state.folders[0] ?? "";
}
el.folderSelect.value = state.selectedFolder;
await loadMediaFiles();
}
async function loadMediaFiles() {
const files = await window.panelAPI.media.listFiles(state.selectedFolder);
el.mediaFiles.innerHTML = "";
for (const file of files) {
const item = document.createElement("article");
item.className = "media-item";
if (file.isImage) {
const img = document.createElement("img");
img.src = `file:///${file.absolutePath.replaceAll("\\", "/")}`;
img.alt = file.name;
item.appendChild(img);
}
const meta = document.createElement("div");
meta.className = "meta";
const name = document.createElement("div");
name.className = "name";
name.textContent = file.relativePath;
const del = document.createElement("button");
del.className = "btn btn-danger";
del.textContent = "Delete";
del.addEventListener("click", async () => {
if (!window.confirm(`Delete ${file.relativePath}?`)) return;
await window.panelAPI.media.deleteFile(file.relativePath);
await loadMediaFiles();
notify(`Deleted: ${file.relativePath}`);
});
meta.append(name, del);
item.appendChild(meta);
el.mediaFiles.appendChild(item);
}
}
async function createFolder() {
const folder = el.newFolder.value.trim();
if (!folder) return;
await window.panelAPI.media.createFolder(folder);
el.newFolder.value = "";
await loadMediaFolders();
state.selectedFolder = folder.replaceAll("\\", "/").replace(/^\/+/, "");
el.folderSelect.value = state.selectedFolder;
await loadMediaFiles();
}
async function uploadFiles() {
await window.panelAPI.media.upload(state.selectedFolder);
await loadMediaFiles();
notify("Upload completed.");
}
async function refreshPublishStatus() {
const status = await window.panelAPI.publish.status();
el.gitStatus.textContent = status.length
? status.join("\n")
: "No pending changes in data/, public/ or content/.";
}
async function publishNow() {
const result = await window.panelAPI.publish.run(el.commitMessage.value);
el.publishLog.textContent = JSON.stringify(result, null, 2);
await refreshPublishStatus();
}
function bindEvents() {
for (const button of el.tabs) {
button.addEventListener("click", () => {
const tab = button.dataset.tab;
if (tab === "collection") {
setActiveTab("collection", { collectionKey: button.dataset.collectionKey });
return;
}
setActiveTab(tab);
});
}
el.refreshBlogFiles.addEventListener("click", () => void loadBlogFiles());
el.newBlogFile.addEventListener("click", () => createBlogFile());
el.deleteBlogFile.addEventListener("click", () => void removeBlogFile().catch((error) => notify(String(error.message || error))));
el.saveBlogFile.addEventListener("click", () => void saveBlogFile().catch((error) => notify(String(error.message || error))));
el.refreshCollection.addEventListener("click", () =>
void loadCollection(state.collection.key, { keepSelection: true }).catch((error) => notify(String(error.message || error)))
);
el.newCollectionItem.addEventListener("click", () => startCollectionCreateMode(getCollectionConfig()));
el.deleteCollectionItem.addEventListener("click", () => void removeCollectionItem().catch((error) => notify(String(error.message || error))));
el.saveCollectionItem.addEventListener("click", () => void saveCollectionItem().catch((error) => notify(String(error.message || error))));
el.folderSelect.addEventListener("change", () => {
state.selectedFolder = el.folderSelect.value;
void loadMediaFiles();
});
el.refreshMedia.addEventListener("click", () => void loadMediaFolders());
el.createFolder.addEventListener("click", () => void createFolder().catch((error) => notify(String(error.message || error))));
el.uploadFile.addEventListener("click", () => void uploadFiles().catch((error) => notify(String(error.message || error))));
el.refreshStatus.addEventListener("click", () => void refreshPublishStatus());
el.publishBtn.addEventListener("click", () => void publishNow().catch((error) => notify(String(error.message || error))));
}
async function init() {
bindEvents();
await loadBlogFiles();
if (!state.selectedBlogSlug) createBlogFile();
await loadCollection(state.collection.key);
await loadMediaFolders();
await refreshPublishStatus();
setActiveTab("blog");
}
void init().catch((error) => {
notify(`Initialization error: ${String(error.message || error)}`);
});