feat: implement data management panel and blog data utilities
This commit is contained in:
@@ -34,7 +34,6 @@ let WORKSPACE_ROOT = resolveWorkspaceRoot();
|
||||
let DATA_DIR = path.join(WORKSPACE_ROOT, "data");
|
||||
let PUBLIC_DIR = path.join(WORKSPACE_ROOT, "public");
|
||||
let BLOG_CONTENT_DIR = path.join(WORKSPACE_ROOT, "content", "blog");
|
||||
let PODCAST_CONTENT_DIR = path.join(WORKSPACE_ROOT, "content", "podcasts");
|
||||
let SNIPPETS_CONTENT_DIR = path.join(WORKSPACE_ROOT, "content", "snippets");
|
||||
|
||||
function setWorkspaceRoot(nextRoot) {
|
||||
@@ -42,7 +41,6 @@ function setWorkspaceRoot(nextRoot) {
|
||||
DATA_DIR = path.join(WORKSPACE_ROOT, "data");
|
||||
PUBLIC_DIR = path.join(WORKSPACE_ROOT, "public");
|
||||
BLOG_CONTENT_DIR = path.join(WORKSPACE_ROOT, "content", "blog");
|
||||
PODCAST_CONTENT_DIR = path.join(WORKSPACE_ROOT, "content", "podcasts");
|
||||
SNIPPETS_CONTENT_DIR = path.join(WORKSPACE_ROOT, "content", "snippets");
|
||||
}
|
||||
|
||||
@@ -60,7 +58,6 @@ async function ensureWorkspaceStructure() {
|
||||
{ label: "data", target: DATA_DIR },
|
||||
{ label: "public", target: PUBLIC_DIR },
|
||||
{ label: "content/blog", target: BLOG_CONTENT_DIR },
|
||||
{ label: "content/podcasts", target: PODCAST_CONTENT_DIR },
|
||||
{ label: "content/snippets", target: SNIPPETS_CONTENT_DIR },
|
||||
];
|
||||
|
||||
@@ -131,18 +128,6 @@ function validateBlogSlug(slug) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validatePodcastKind(kind) {
|
||||
const normalized = String(kind || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (normalized !== "yazilim" && normalized !== "masa-basi") {
|
||||
throw new Error("Podcast kind must be one of: yazilim, masa-basi");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function runGit(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile("git", args, { cwd: WORKSPACE_ROOT }, (error, stdout, stderr) => {
|
||||
@@ -497,94 +482,6 @@ async function deleteBlogBySlug(slug) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function mapMarkdownToPodcast(kind, fileName, raw) {
|
||||
const parsed = matter(raw);
|
||||
const slug = fileName.replace(/\.md$/i, "");
|
||||
|
||||
return {
|
||||
slug,
|
||||
title: String(parsed.data.title || slug),
|
||||
date: String(parsed.data.date || ""),
|
||||
youtubeUrl: String(parsed.data.youtubeUrl || ""),
|
||||
spotifyUrl: String(parsed.data.spotifyUrl || ""),
|
||||
podcast: kind,
|
||||
markdown: String(parsed.content || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function listPodcastEpisodes(kind) {
|
||||
const safeKind = validatePodcastKind(kind);
|
||||
const podcastDir = assertSafePath(PODCAST_CONTENT_DIR, path.join(PODCAST_CONTENT_DIR, safeKind));
|
||||
|
||||
await fs.mkdir(podcastDir, { recursive: true });
|
||||
const files = await fs.readdir(podcastDir, { withFileTypes: true });
|
||||
const markdownFiles = files
|
||||
.filter((item) => item.isFile() && item.name.endsWith(".md"))
|
||||
.map((item) => item.name)
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
|
||||
const episodes = await Promise.all(
|
||||
markdownFiles.map(async (fileName) => {
|
||||
const target = assertSafePath(podcastDir, path.join(podcastDir, fileName));
|
||||
const raw = await fs.readFile(target, "utf8");
|
||||
return mapMarkdownToPodcast(safeKind, fileName, raw);
|
||||
}),
|
||||
);
|
||||
|
||||
return episodes;
|
||||
}
|
||||
|
||||
async function upsertPodcastEpisode(kind, { originalSlug, episode }) {
|
||||
const safeKind = validatePodcastKind(kind);
|
||||
if (!episode || typeof episode !== "object") {
|
||||
throw new Error("Episode payload is invalid.");
|
||||
}
|
||||
|
||||
const slug = validateBlogSlug(episode.slug);
|
||||
const previousSlug = originalSlug ? validateBlogSlug(originalSlug) : null;
|
||||
|
||||
const frontmatter = {
|
||||
title: String(episode.title || slug),
|
||||
date: String(episode.date || ""),
|
||||
youtubeUrl: String(episode.youtubeUrl || ""),
|
||||
spotifyUrl: String(episode.spotifyUrl || ""),
|
||||
podcast: safeKind,
|
||||
};
|
||||
|
||||
const markdownBody = String(episode.markdown || "").trim();
|
||||
const raw = matter.stringify(markdownBody ? `${markdownBody}\n` : "", frontmatter);
|
||||
|
||||
const podcastDir = assertSafePath(PODCAST_CONTENT_DIR, path.join(PODCAST_CONTENT_DIR, safeKind));
|
||||
await fs.mkdir(podcastDir, { recursive: true });
|
||||
|
||||
const nextPath = assertSafePath(podcastDir, path.join(podcastDir, `${slug}.md`));
|
||||
const previousPath =
|
||||
previousSlug && previousSlug !== slug
|
||||
? assertSafePath(podcastDir, path.join(podcastDir, `${previousSlug}.md`))
|
||||
: null;
|
||||
|
||||
await fs.writeFile(nextPath, raw, "utf8");
|
||||
|
||||
if (previousPath) {
|
||||
try {
|
||||
await fs.unlink(previousPath);
|
||||
} catch {
|
||||
// ignore missing previous file
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, slug };
|
||||
}
|
||||
|
||||
async function deletePodcastEpisode(kind, slug) {
|
||||
const safeKind = validatePodcastKind(kind);
|
||||
const normalized = validateBlogSlug(slug);
|
||||
const podcastDir = assertSafePath(PODCAST_CONTENT_DIR, path.join(PODCAST_CONTENT_DIR, safeKind));
|
||||
const target = assertSafePath(podcastDir, path.join(podcastDir, `${normalized}.md`));
|
||||
await fs.unlink(target);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function mapMarkdownToSnippet(fileName, raw) {
|
||||
const parsed = matter(raw);
|
||||
const slug = fileName.replace(/\.md$/i, "");
|
||||
@@ -824,9 +721,6 @@ ipcMain.handle("data:delete", async (_, fileName) => deleteDataFile(fileName));
|
||||
ipcMain.handle("blog:list", async () => listBlogs());
|
||||
ipcMain.handle("blog:upsert", async (_, payload) => upsertBlog(payload));
|
||||
ipcMain.handle("blog:delete", async (_, slug) => deleteBlogBySlug(slug));
|
||||
ipcMain.handle("podcast:list", async (_, kind) => listPodcastEpisodes(kind));
|
||||
ipcMain.handle("podcast:upsert", async (_, payload) => upsertPodcastEpisode(payload.kind, payload));
|
||||
ipcMain.handle("podcast:delete", async (_, payload) => deletePodcastEpisode(payload.kind, payload.slug));
|
||||
|
||||
ipcMain.handle("snippet:list", async () => listSnippets());
|
||||
ipcMain.handle("snippet:upsert", async (_, payload) => upsertSnippet(payload));
|
||||
|
||||
@@ -15,11 +15,6 @@ contextBridge.exposeInMainWorld("panelAPI", {
|
||||
upsert: (payload) => ipcRenderer.invoke("blog:upsert", payload),
|
||||
delete: (slug) => ipcRenderer.invoke("blog:delete", slug),
|
||||
},
|
||||
podcast: {
|
||||
list: (kind) => ipcRenderer.invoke("podcast:list", kind),
|
||||
upsert: (payload) => ipcRenderer.invoke("podcast:upsert", payload),
|
||||
delete: (payload) => ipcRenderer.invoke("podcast:delete", payload),
|
||||
},
|
||||
snippet: {
|
||||
list: () => ipcRenderer.invoke("snippet:list"),
|
||||
upsert: (payload) => ipcRenderer.invoke("snippet:upsert", payload),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { BLOG_CATEGORIES, COLLECTION_CONFIGS, PODCAST_LABELS } from "./configs.js";
|
||||
import { BLOG_CATEGORIES, COLLECTION_CONFIGS } from "./configs.js";
|
||||
|
||||
const state = {
|
||||
activeTab: "blog",
|
||||
@@ -15,12 +15,6 @@ const state = {
|
||||
editingIndex: null,
|
||||
formInputs: {},
|
||||
},
|
||||
podcast: {
|
||||
kind: "yazilim",
|
||||
episodes: [],
|
||||
selectedSlug: "",
|
||||
originalSlug: "",
|
||||
},
|
||||
};
|
||||
|
||||
const el = {
|
||||
@@ -28,7 +22,6 @@ const el = {
|
||||
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"),
|
||||
},
|
||||
@@ -56,19 +49,6 @@ const el = {
|
||||
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"),
|
||||
@@ -95,17 +75,13 @@ function getCollectionConfig(key = state.collection.key) {
|
||||
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;
|
||||
: buttonTab === tab;
|
||||
button.classList.toggle("active", isActive);
|
||||
}
|
||||
|
||||
@@ -116,10 +92,6 @@ function setActiveTab(tab, options = {}) {
|
||||
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() {
|
||||
@@ -273,98 +245,6 @@ async function removeBlogFile(slugArg) {
|
||||
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 };
|
||||
@@ -706,10 +586,6 @@ function bindEvents() {
|
||||
setActiveTab("collection", { collectionKey: button.dataset.collectionKey });
|
||||
return;
|
||||
}
|
||||
if (tab === "podcast") {
|
||||
setActiveTab("podcast", { podcastKind: button.dataset.podcastKind });
|
||||
return;
|
||||
}
|
||||
setActiveTab(tab);
|
||||
});
|
||||
}
|
||||
@@ -726,11 +602,6 @@ function bindEvents() {
|
||||
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();
|
||||
@@ -748,7 +619,6 @@ async function init() {
|
||||
await loadBlogFiles();
|
||||
if (!state.selectedBlogSlug) createBlogFile();
|
||||
await loadCollection(state.collection.key);
|
||||
await loadPodcastFiles(state.podcast.kind);
|
||||
await loadMediaFolders();
|
||||
await refreshPublishStatus();
|
||||
setActiveTab("blog");
|
||||
|
||||
@@ -482,8 +482,3 @@ export const COLLECTION_CONFIGS = {
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const PODCAST_LABELS = {
|
||||
yazilim: "Podcast Yazilim",
|
||||
"masa-basi": "Podcast Masa Basi",
|
||||
};
|
||||
|
||||
@@ -26,13 +26,6 @@
|
||||
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"
|
||||
@@ -223,72 +216,6 @@
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user