feat(panel): add electron dashboards for data, podcasts, media, and publish
This commit is contained in:
@@ -0,0 +1,742 @@
|
||||
|
||||
import { COLLECTION_CONFIGS, PODCAST_LABELS } from "./configs.js";
|
||||
|
||||
const state = {
|
||||
activeTab: "blog",
|
||||
folders: [],
|
||||
selectedFolder: "",
|
||||
blogPosts: [],
|
||||
selectedBlogSlug: "",
|
||||
blogOriginalSlug: "",
|
||||
collection: {
|
||||
key: "announcement",
|
||||
items: [],
|
||||
selectedIndex: null,
|
||||
editingIndex: null,
|
||||
formInputs: {},
|
||||
},
|
||||
podcast: {
|
||||
kind: "yazilim",
|
||||
episodes: [],
|
||||
selectedSlug: "",
|
||||
originalSlug: "",
|
||||
},
|
||||
};
|
||||
|
||||
const el = {
|
||||
tabs: document.querySelectorAll(".tab-btn"),
|
||||
panels: {
|
||||
blog: document.getElementById("tab-blog"),
|
||||
collection: document.getElementById("tab-collection"),
|
||||
podcast: document.getElementById("tab-podcast"),
|
||||
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"),
|
||||
podcastSidebarTitle: document.getElementById("podcast-sidebar-title"),
|
||||
podcastCardList: document.getElementById("podcast-card-list"),
|
||||
refreshPodcastFiles: document.getElementById("refresh-podcast-files"),
|
||||
newPodcastFile: document.getElementById("new-podcast-file"),
|
||||
deletePodcastFile: document.getElementById("delete-podcast-file"),
|
||||
podcastEditorTitle: document.getElementById("podcast-editor-title"),
|
||||
podcastSlug: document.getElementById("podcast-slug"),
|
||||
podcastTitle: document.getElementById("podcast-title"),
|
||||
podcastDate: document.getElementById("podcast-date"),
|
||||
podcastYoutubeUrl: document.getElementById("podcast-youtube-url"),
|
||||
podcastSpotifyUrl: document.getElementById("podcast-spotify-url"),
|
||||
podcastEditor: document.getElementById("podcast-editor"),
|
||||
savePodcastFile: document.getElementById("save-podcast-file"),
|
||||
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;
|
||||
if (tab === "podcast" && options.podcastKind) state.podcast.kind = options.podcastKind;
|
||||
|
||||
for (const button of el.tabs) {
|
||||
const buttonTab = button.dataset.tab;
|
||||
const isCollection = buttonTab === "collection";
|
||||
const isPodcast = buttonTab === "podcast";
|
||||
const isActive = isCollection
|
||||
? tab === "collection" && button.dataset.collectionKey === state.collection.key
|
||||
: isPodcast
|
||||
? tab === "podcast" && button.dataset.podcastKind === state.podcast.kind
|
||||
: 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)));
|
||||
}
|
||||
|
||||
if (tab === "podcast") {
|
||||
void loadPodcastFiles(state.podcast.kind).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 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 || "";
|
||||
el.blogCategory.value = 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 emptyPodcastDraft(kind = state.podcast.kind) {
|
||||
return { slug: "", title: "", date: "", youtubeUrl: "", spotifyUrl: "", podcast: kind, markdown: "" };
|
||||
}
|
||||
|
||||
function fillPodcastForm(episode) {
|
||||
const draft = episode || emptyPodcastDraft();
|
||||
el.podcastSlug.value = draft.slug || "";
|
||||
el.podcastTitle.value = draft.title || "";
|
||||
el.podcastDate.value = draft.date || "";
|
||||
el.podcastYoutubeUrl.value = draft.youtubeUrl || "";
|
||||
el.podcastSpotifyUrl.value = draft.spotifyUrl || "";
|
||||
el.podcastEditor.value = draft.markdown || "";
|
||||
}
|
||||
|
||||
function selectPodcast(slug) {
|
||||
const episode = state.podcast.episodes.find((item) => item.slug === slug);
|
||||
if (!episode) return;
|
||||
state.podcast.selectedSlug = slug;
|
||||
state.podcast.originalSlug = slug;
|
||||
el.podcastEditorTitle.textContent = `Edit Episode: ${slug}`;
|
||||
fillPodcastForm(episode);
|
||||
renderPostCards(el.podcastCardList, state.podcast.episodes, state.podcast.selectedSlug, selectPodcast, (value) => void removePodcastFile(value));
|
||||
}
|
||||
function createPodcastFile() {
|
||||
state.podcast.selectedSlug = "";
|
||||
state.podcast.originalSlug = "";
|
||||
el.podcastEditorTitle.textContent = `Create Episode (${PODCAST_LABELS[state.podcast.kind] || state.podcast.kind})`;
|
||||
fillPodcastForm(emptyPodcastDraft(state.podcast.kind));
|
||||
renderPostCards(el.podcastCardList, state.podcast.episodes, state.podcast.selectedSlug, selectPodcast, (slug) => void removePodcastFile(slug));
|
||||
}
|
||||
|
||||
async function loadPodcastFiles(kind = state.podcast.kind) {
|
||||
state.podcast.kind = kind;
|
||||
state.podcast.episodes = await window.panelAPI.podcast.list(kind);
|
||||
el.podcastSidebarTitle.textContent = PODCAST_LABELS[kind] || "Podcast";
|
||||
renderPostCards(el.podcastCardList, state.podcast.episodes, state.podcast.selectedSlug, selectPodcast, (value) => void removePodcastFile(value));
|
||||
|
||||
const selected = state.podcast.episodes.find((item) => item.slug === state.podcast.selectedSlug);
|
||||
if (selected) {
|
||||
selectPodcast(selected.slug);
|
||||
} else if (state.podcast.episodes.length > 0) {
|
||||
selectPodcast(state.podcast.episodes[0].slug);
|
||||
} else {
|
||||
createPodcastFile();
|
||||
}
|
||||
}
|
||||
|
||||
function currentPodcastDraft() {
|
||||
return {
|
||||
slug: el.podcastSlug.value.trim(),
|
||||
title: el.podcastTitle.value.trim(),
|
||||
date: el.podcastDate.value.trim(),
|
||||
youtubeUrl: el.podcastYoutubeUrl.value.trim(),
|
||||
spotifyUrl: el.podcastSpotifyUrl.value.trim(),
|
||||
podcast: state.podcast.kind,
|
||||
markdown: el.podcastEditor.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function savePodcastFile() {
|
||||
const episode = currentPodcastDraft();
|
||||
if (!episode.slug || !episode.title) {
|
||||
notify("Slug and title are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.panelAPI.podcast.upsert({
|
||||
kind: state.podcast.kind,
|
||||
originalSlug: state.podcast.originalSlug || undefined,
|
||||
episode,
|
||||
});
|
||||
|
||||
await loadPodcastFiles(state.podcast.kind);
|
||||
state.podcast.selectedSlug = result.slug;
|
||||
state.podcast.originalSlug = result.slug;
|
||||
selectPodcast(result.slug);
|
||||
notify(`Saved podcast episode: ${result.slug}`);
|
||||
}
|
||||
|
||||
async function removePodcastFile(slugArg) {
|
||||
const targetSlug = slugArg || state.podcast.selectedSlug;
|
||||
if (!targetSlug) return;
|
||||
if (!window.confirm(`Delete podcast episode ${targetSlug}?`)) return;
|
||||
|
||||
await window.panelAPI.podcast.delete({ kind: state.podcast.kind, slug: targetSlug });
|
||||
state.podcast.selectedSlug = "";
|
||||
state.podcast.originalSlug = "";
|
||||
createPodcastFile();
|
||||
await loadPodcastFiles(state.podcast.kind);
|
||||
notify("Podcast episode 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;
|
||||
}
|
||||
if (tab === "podcast") {
|
||||
setActiveTab("podcast", { podcastKind: button.dataset.podcastKind });
|
||||
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.refreshPodcastFiles.addEventListener("click", () => void loadPodcastFiles(state.podcast.kind).catch((error) => notify(String(error.message || error))));
|
||||
el.newPodcastFile.addEventListener("click", () => createPodcastFile());
|
||||
el.deletePodcastFile.addEventListener("click", () => void removePodcastFile().catch((error) => notify(String(error.message || error))));
|
||||
el.savePodcastFile.addEventListener("click", () => void savePodcastFile().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 loadPodcastFiles(state.podcast.kind);
|
||||
await loadMediaFolders();
|
||||
await refreshPublishStatus();
|
||||
setActiveTab("blog");
|
||||
}
|
||||
|
||||
void init().catch((error) => {
|
||||
notify(`Initialization error: ${String(error.message || error)}`);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
|
||||
export const COLLECTION_CONFIGS = {
|
||||
announcement: {
|
||||
label: "Announcement",
|
||||
itemLabel: "Announcement",
|
||||
fileName: "site-settings.ts",
|
||||
exportName: "ANNOUNCEMENT_ITEMS",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "main-announcement" },
|
||||
{ key: "text", label: "Text", type: "textarea", full: true, required: true, placeholder: "Bu hafta yeni bilesenler eklendi!" },
|
||||
{ key: "actionLabel", label: "Action Label", required: true, placeholder: "Incele ->" },
|
||||
{ key: "actionHref", label: "Action Link", type: "url", required: true, placeholder: "https://..." },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.id || "Announcement",
|
||||
meta: [item.actionLabel, item.actionHref],
|
||||
footer: item.text,
|
||||
}),
|
||||
},
|
||||
bookmarks: {
|
||||
label: "Bookmarks",
|
||||
itemLabel: "Bookmark",
|
||||
fileName: "bookmarks.ts",
|
||||
exportName: "BOOKMARKS",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "bookmark-poyraz-ui" },
|
||||
{ key: "title", label: "Title", required: true, placeholder: "Poyraz UI" },
|
||||
{ key: "href", label: "Link", type: "url", required: true, placeholder: "https://..." },
|
||||
{ key: "tag", label: "Tag", required: true, placeholder: "UI Kit" },
|
||||
{ key: "description", label: "Description", type: "textarea", full: true, required: true, placeholder: "Short description" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.title || item.id || "Untitled bookmark",
|
||||
meta: [item.tag, item.href],
|
||||
footer: item.description,
|
||||
}),
|
||||
},
|
||||
certificates: {
|
||||
label: "Certificates",
|
||||
itemLabel: "Certificate",
|
||||
fileName: "certificates.ts",
|
||||
exportName: "certificates",
|
||||
fields: [
|
||||
{ key: "name", label: "Name", required: true, placeholder: "Certificate name" },
|
||||
{ key: "organization", label: "Organization", required: true, placeholder: "Udemy" },
|
||||
{ key: "date", label: "Date", required: true, placeholder: "June 2025" },
|
||||
{ key: "category", label: "Category", required: true, placeholder: "mobile" },
|
||||
{ key: "image", label: "Image Path", required: true, placeholder: "/certificates/reactnative.png" },
|
||||
{ key: "description", label: "Description", type: "textarea", full: true, required: true, placeholder: "What this certificate represents" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.name || "Untitled certificate",
|
||||
meta: [item.organization, item.date, item.category],
|
||||
footer: item.image,
|
||||
}),
|
||||
},
|
||||
education: {
|
||||
label: "Education",
|
||||
itemLabel: "Education Item",
|
||||
fileName: "education.ts",
|
||||
exportName: "EDUCATION",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "ostim-university" },
|
||||
{ key: "title", label: "Title", required: true, placeholder: "Software Engineering - 2nd Year Student" },
|
||||
{ key: "institution", label: "Institution", required: true, placeholder: "OSTIM Technical University" },
|
||||
{ key: "period", label: "Period", required: true, placeholder: "2024 - Present" },
|
||||
{ key: "description", label: "Description", type: "textarea", full: true, required: true, placeholder: "Education summary" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.title || item.id || "Education",
|
||||
meta: [item.institution, item.period],
|
||||
footer: item.description,
|
||||
}),
|
||||
},
|
||||
experience: {
|
||||
label: "Experience",
|
||||
itemLabel: "Experience Item",
|
||||
fileName: "experience.ts",
|
||||
exportName: "EXPERIENCE",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "omedya-part-time" },
|
||||
{ key: "role", label: "Role", required: true, placeholder: "Fullstack Developer" },
|
||||
{ key: "company", label: "Company", required: true, placeholder: "Omedya Bilisim A.S" },
|
||||
{ key: "period", label: "Period", required: true, placeholder: "August 2025 - Present" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.role || item.id || "Experience",
|
||||
meta: [item.company, item.period],
|
||||
}),
|
||||
},
|
||||
references: {
|
||||
label: "References",
|
||||
itemLabel: "Reference",
|
||||
fileName: "references.ts",
|
||||
exportName: "REFERENCES",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "ali-korkmaz" },
|
||||
{ key: "author", label: "Author", required: true, placeholder: "Ali Korkmaz" },
|
||||
{ key: "role", label: "Role", required: true, placeholder: "Musteri - 2025" },
|
||||
{ key: "avatar", label: "Avatar Path", required: true, placeholder: "/avatars/ali.png" },
|
||||
{ key: "rating", label: "Rating", type: "number", min: 1, max: 5, step: 1, placeholder: "5" },
|
||||
{ key: "profileHref", label: "Profile Link", type: "url", placeholder: "https://linkedin.com/in/..." },
|
||||
{ key: "quote", label: "Quote", type: "textarea", full: true, required: true, placeholder: "Reference quote" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.author || item.id || "Reference",
|
||||
meta: [item.role, item.rating ? `Rating: ${item.rating}` : ""],
|
||||
footer: item.quote,
|
||||
}),
|
||||
},
|
||||
projectsMobile: {
|
||||
label: "Projects Mobile",
|
||||
itemLabel: "Mobile Project",
|
||||
fileName: "projects.ts",
|
||||
exportName: "MOBILE_APPS",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "mobile-habit-flow" },
|
||||
{ key: "title", label: "Title", required: true, placeholder: "Habit Flow" },
|
||||
{ key: "badge", label: "Badge", placeholder: "React Native" },
|
||||
{ key: "image", label: "Image Path", required: true, placeholder: "/images/hero1.png" },
|
||||
{ key: "href", label: "Link", type: "url", placeholder: "https://..." },
|
||||
{ key: "description", label: "Description", type: "textarea", full: true, required: true, placeholder: "Project summary" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.title || item.id || "Project",
|
||||
meta: [item.badge, item.href],
|
||||
footer: item.description,
|
||||
}),
|
||||
},
|
||||
projectsWeb: {
|
||||
label: "Projects Web",
|
||||
itemLabel: "Web Project",
|
||||
fileName: "projects.ts",
|
||||
exportName: "WEB_APPS",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "web-portfolio" },
|
||||
{ key: "title", label: "Title", required: true, placeholder: "Personal Portfolio" },
|
||||
{ key: "badge", label: "Badge", placeholder: "Next.js" },
|
||||
{ key: "image", label: "Image Path", required: true, placeholder: "/news/design.svg" },
|
||||
{ key: "href", label: "Link", type: "url", placeholder: "https://..." },
|
||||
{ key: "description", label: "Description", type: "textarea", full: true, required: true, placeholder: "Project summary" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.title || item.id || "Project",
|
||||
meta: [item.badge, item.href],
|
||||
footer: item.description,
|
||||
}),
|
||||
},
|
||||
projectsFigma: {
|
||||
label: "Projects Figma",
|
||||
itemLabel: "Figma Template",
|
||||
fileName: "projects.ts",
|
||||
exportName: "FIGMA_TEMPLATES",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "figma-minimal-saas" },
|
||||
{ key: "title", label: "Title", required: true, placeholder: "Minimal SaaS Landing Kit" },
|
||||
{ key: "badge", label: "Badge", placeholder: "Figma" },
|
||||
{ key: "image", label: "Image Path", required: true, placeholder: "/news/design.svg" },
|
||||
{ key: "href", label: "Link", type: "url", placeholder: "https://..." },
|
||||
{ key: "description", label: "Description", type: "textarea", full: true, required: true, placeholder: "Project summary" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.title || item.id || "Project",
|
||||
meta: [item.badge, item.href],
|
||||
footer: item.description,
|
||||
}),
|
||||
},
|
||||
community: {
|
||||
label: "Community",
|
||||
itemLabel: "Community Item",
|
||||
fileName: "volunteer-community.ts",
|
||||
exportName: "VOLUNTEER_COMMUNITY_ITEMS",
|
||||
fields: [
|
||||
{ key: "id", label: "ID", required: true, placeholder: "youtube" },
|
||||
{ key: "title", label: "Title", required: true, placeholder: "YouTube" },
|
||||
{ key: "timeline", label: "Timeline", required: true, placeholder: "2025 - Present" },
|
||||
{ key: "link", label: "Link", type: "url", placeholder: "https://..." },
|
||||
{ key: "focus", label: "Focus", type: "textarea", full: true, required: true, placeholder: "Community contribution summary" },
|
||||
],
|
||||
card: (item) => ({
|
||||
title: item.title || item.id || "Community",
|
||||
meta: [item.timeline, item.link],
|
||||
footer: item.focus,
|
||||
}),
|
||||
},
|
||||
youtube: {
|
||||
label: "YouTube Videos",
|
||||
itemLabel: "Video Link",
|
||||
fileName: "youtube-videos.ts",
|
||||
exportName: "YOUTUBE_VIDEO_LINKS",
|
||||
fields: [
|
||||
{ key: "url", label: "Video URL", type: "url", required: true, placeholder: "https://www.youtube.com/watch?v=..." },
|
||||
],
|
||||
deserializeItem: (raw) => ({ url: String(raw || "") }),
|
||||
serializeItem: (item) => String(item.url || "").trim(),
|
||||
card: (item) => ({
|
||||
title: item.url || "YouTube Link",
|
||||
meta: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const PODCAST_LABELS = {
|
||||
yazilim: "Podcast Yazilim",
|
||||
"masa-basi": "Podcast Masa Basi",
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Portfolio Data Panel</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Portfolio Data Panel</h1>
|
||||
<p>Manage blog posts, dashboard collections, media files, and publish updates.</p>
|
||||
</div>
|
||||
<div class="chip">Electron Monorepo</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab-btn active" data-tab="blog">Blog</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="announcement">Announcement</button>
|
||||
<button class="tab-btn" data-tab="podcast" data-podcast-kind="yazilim">Podcast Yazilim</button>
|
||||
<button class="tab-btn" data-tab="podcast" data-podcast-kind="masa-basi">Podcast Masa Basi</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="bookmarks">Bookmarks</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="certificates">Certificates</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="education">Education</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="experience">Experience</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="references">References</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="projectsMobile">Projects Mobile</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="projectsWeb">Projects Web</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="projectsFigma">Projects Figma</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="community">Community</button>
|
||||
<button class="tab-btn" data-tab="collection" data-collection-key="youtube">YouTube</button>
|
||||
<button class="tab-btn" data-tab="media">Media</button>
|
||||
<button class="tab-btn" data-tab="publish">Publish</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="tab-blog" class="tab-panel active">
|
||||
<div class="split">
|
||||
<aside class="card sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Blog Posts</h2>
|
||||
<button id="refresh-blog-files" class="btn btn-ghost">Refresh</button>
|
||||
</div>
|
||||
<div id="blog-card-list" class="blog-card-list"></div>
|
||||
<button id="new-blog-file" class="btn mt-8">New Blog Post</button>
|
||||
</aside>
|
||||
|
||||
<section class="card editor">
|
||||
<div class="editor-head">
|
||||
<h2 id="blog-editor-title">Create Blog Post</h2>
|
||||
<div class="row">
|
||||
<button id="delete-blog-file" class="btn btn-danger">Delete</button>
|
||||
<button id="save-blog-file" class="btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blog-form-grid">
|
||||
<div>
|
||||
<label for="blog-slug">Slug</label>
|
||||
<input id="blog-slug" placeholder="building-minimal-design-systems" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="blog-title">Title</label>
|
||||
<input id="blog-title" placeholder="Building Minimal Design Systems" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="blog-category">Category</label>
|
||||
<input id="blog-category" placeholder="React" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="blog-date">Date</label>
|
||||
<input id="blog-date" placeholder="March 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="blog-read-time">Read Time</label>
|
||||
<input id="blog-read-time" placeholder="12 min read" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="blog-author">Author</label>
|
||||
<input id="blog-author" placeholder="Poyraz Avsever" />
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="blog-cover-image">Cover Image</label>
|
||||
<input id="blog-cover-image" placeholder="/news/design.svg" />
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="blog-excerpt">Excerpt</label>
|
||||
<textarea id="blog-excerpt" class="small-editor" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="blog-editor">Markdown Content</label>
|
||||
<textarea id="blog-editor" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-collection" class="tab-panel">
|
||||
<div class="split">
|
||||
<aside class="card sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 id="collection-sidebar-title">Bookmarks</h2>
|
||||
<button id="refresh-collection" class="btn btn-ghost">Refresh</button>
|
||||
</div>
|
||||
<div id="collection-card-list" class="blog-card-list"></div>
|
||||
<button id="new-collection-item" class="btn mt-8">New Item</button>
|
||||
</aside>
|
||||
|
||||
<section class="card editor">
|
||||
<div class="editor-head">
|
||||
<h2 id="collection-editor-title">Create Item</h2>
|
||||
<div class="row">
|
||||
<button id="delete-collection-item" class="btn btn-danger">Delete</button>
|
||||
<button id="save-collection-item" class="btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="collection-helper" class="hint">Fill inputs and save to update the related data export.</p>
|
||||
<div id="collection-form-grid" class="blog-form-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-podcast" class="tab-panel">
|
||||
<div class="split">
|
||||
<aside class="card sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2 id="podcast-sidebar-title">Podcast Episodes</h2>
|
||||
<button id="refresh-podcast-files" class="btn btn-ghost">Refresh</button>
|
||||
</div>
|
||||
<div id="podcast-card-list" class="blog-card-list"></div>
|
||||
<button id="new-podcast-file" class="btn mt-8">New Episode</button>
|
||||
</aside>
|
||||
|
||||
<section class="card editor">
|
||||
<div class="editor-head">
|
||||
<h2 id="podcast-editor-title">Create Episode</h2>
|
||||
<div class="row">
|
||||
<button id="delete-podcast-file" class="btn btn-danger">Delete</button>
|
||||
<button id="save-podcast-file" class="btn">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blog-form-grid">
|
||||
<div>
|
||||
<label for="podcast-slug">Slug</label>
|
||||
<input id="podcast-slug" placeholder="2026-03-09-foundations-over-tools" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="podcast-title">Title</label>
|
||||
<input id="podcast-title" placeholder="Foundations Over Tools" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="podcast-date">Date</label>
|
||||
<input id="podcast-date" placeholder="2026-03-09" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="podcast-youtube-url">YouTube URL</label>
|
||||
<input id="podcast-youtube-url" placeholder="https://www.youtube.com/watch?v=..." />
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="podcast-spotify-url">Spotify URL</label>
|
||||
<input id="podcast-spotify-url" placeholder="https://open.spotify.com/show/..." />
|
||||
</div>
|
||||
<div class="full">
|
||||
<label for="podcast-editor">Markdown Content</label>
|
||||
<textarea id="podcast-editor" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-media" class="tab-panel">
|
||||
<div class="split">
|
||||
<section class="card">
|
||||
<div class="row between">
|
||||
<h2>Public Folders</h2>
|
||||
<button id="refresh-media" class="btn btn-ghost">Refresh</button>
|
||||
</div>
|
||||
|
||||
<label for="folder-select">Target Folder</label>
|
||||
<select id="folder-select"></select>
|
||||
|
||||
<div class="row mt-8">
|
||||
<input id="new-folder" placeholder="e.g. blog/covers" />
|
||||
<button id="create-folder" class="btn">Create</button>
|
||||
</div>
|
||||
|
||||
<div class="row mt-8">
|
||||
<button id="upload-file" class="btn">Upload Image</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Files</h2>
|
||||
<div id="media-files" class="media-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tab-publish" class="tab-panel">
|
||||
<div class="card publish-card">
|
||||
<div class="row between">
|
||||
<h2>Publish Changes</h2>
|
||||
<button id="refresh-status" class="btn btn-ghost">Refresh Status</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">This will stage <code>data/</code>, <code>public/</code> and <code>content/</code>, create commit, and push.</p>
|
||||
|
||||
<label for="commit-message">Commit Message (optional)</label>
|
||||
<input id="commit-message" placeholder="Auto-generated if empty" />
|
||||
|
||||
<div class="row mt-8">
|
||||
<button id="publish-btn" class="btn">Publish</button>
|
||||
</div>
|
||||
|
||||
<h3>Git Status</h3>
|
||||
<pre id="git-status" class="log"></pre>
|
||||
|
||||
<h3>Publish Log</h3>
|
||||
<pre id="publish-log" class="log"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,332 @@
|
||||
:root {
|
||||
--bg: #f4f5f7;
|
||||
--card: #ffffff;
|
||||
--muted: #6b7280;
|
||||
--border: #d8dbe2;
|
||||
--text: #111827;
|
||||
--red: #dc2626;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 280px 1fr;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.card.soft {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 680px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.file-list button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-list button.active {
|
||||
border-color: var(--text);
|
||||
background: #f7f7f7;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blog-card-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 620px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.blog-card.active {
|
||||
border-color: var(--text);
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.blog-card .title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blog-card .meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.blog-form-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.blog-form-grid .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row.between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
textarea,
|
||||
select,
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 340px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.small-editor {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.structured {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--text);
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fff;
|
||||
color: var(--red);
|
||||
border-color: #f3b3b3;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
max-height: 680px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.media-item img {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-item .meta {
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.media-item .name {
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.publish-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log {
|
||||
border: 1px solid var(--border);
|
||||
background: #f9fafb;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
min-height: 80px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user