This commit is contained in:
Marek
2026-04-30 21:39:58 +02:00
parent d531bc663d
commit a723f1ea0f
13 changed files with 169 additions and 58 deletions

View File

@@ -1,4 +1,4 @@
import { ItemView, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian"; import { ItemView, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
import { import {
VIEW_TYPE_OVERVIEW, VIEW_TYPE_OVERVIEW,
VIEW_TYPE_DETAILS, VIEW_TYPE_DETAILS,
@@ -17,7 +17,7 @@ import {
createArea, createArea,
createFeature, createFeature,
} from "../fs"; } from "../fs";
import { card, menu, breadcrumb, emptyState, openMarkdown } from "../ui"; import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
import { NameModal } from "../modals/NameModal"; import { NameModal } from "../modals/NameModal";
import { ConfirmModal } from "../modals/ConfirmModal"; import { ConfirmModal } from "../modals/ConfirmModal";
@@ -26,6 +26,7 @@ export interface OverviewState extends Record<string, unknown> {
} }
const NAME_RX = /^[^\\/:*?"<>|]+$/; const NAME_RX = /^[^\\/:*?"<>|]+$/;
const FEATURE_DND_MIME = "application/x-pk-feature";
function validateName(name: string, taken: string[], current?: string): string | null { function validateName(name: string, taken: string[], current?: string): string | null {
if (!name) return "Name darf nicht leer sein."; if (!name) return "Name darf nicht leer sein.";
@@ -108,22 +109,24 @@ export class OverviewView extends ItemView {
]); ]);
const stack = root.createDiv({ cls: "pk-stack" }); const stack = root.createDiv({ cls: "pk-stack" });
this.renderInfoCard(stack, "Core", core, corePath); this.renderInfoCard(stack, core, corePath);
this.renderInfoCard(stack, "Target", target, targetPath); this.renderInfoCard(stack, target, targetPath);
this.renderStoryCard(stack, story, storyPath); this.renderStoryCard(stack, story, storyPath);
this.renderAreas(root); this.renderAreas(root);
} }
private renderInfoCard(parent: HTMLElement, title: string, content: string, path: string): void { private renderInfoCard(parent: HTMLElement, content: string, path: string): void {
card(parent, { const btn = parent.createDiv({
title, cls: "pk-btn-card",
body: content || "(leer)", attr: { role: "button", tabindex: "0" },
onClick: () => });
btn.setText(content || "(leer)");
btn.addEventListener("click", () =>
openMarkdown(this.app, path, { openMarkdown(this.app, path, {
type: VIEW_TYPE_OVERVIEW, type: VIEW_TYPE_OVERVIEW,
state: { project: this.project }, state: { project: this.project },
}), }),
}); );
} }
private renderStoryCard(parent: HTMLElement, content: string, path: string): void { private renderStoryCard(parent: HTMLElement, content: string, path: string): void {
@@ -170,24 +173,59 @@ export class OverviewView extends ItemView {
areaPath(this.project, area.name), areaPath(this.project, area.name),
[], [],
); );
card(stack, { const areaCard = stack.createDiv({
title: area.name, cls: "pk-btn-card pk-area-card",
cls: "pk-area-card", attr: { role: "button", tabindex: "0" },
onClick: () => this.openDetails(area.name), });
onContextMenu: (ev) => areaCard.createEl("strong", { text: area.name });
areaCard.addEventListener("click", () => this.openDetails(area.name));
areaCard.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
menu(ev, [ menu(ev, [
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeature(area.name) }, { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeature(area.name) },
{ title: "Area umbenennen", icon: "pencil", onClick: () => this.openRenameArea(area.name, takenAreas) }, { title: "Area umbenennen", icon: "pencil", onClick: () => this.openRenameArea(area.name, takenAreas) },
{ title: "Area löschen", icon: "trash", onClick: () => this.openDeleteArea(area.name) }, { title: "Area löschen", icon: "trash", onClick: () => this.openDeleteArea(area.name) },
]), ]);
body: (body) => { });
areaCard.addEventListener("dragover", (ev) => {
if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
areaCard.addClass("pk-drop-target");
});
areaCard.addEventListener("dragleave", (ev) => {
const next = ev.relatedTarget as Node | null;
if (next && areaCard.contains(next)) return;
areaCard.removeClass("pk-drop-target");
});
areaCard.addEventListener("drop", async (ev) => {
areaCard.removeClass("pk-drop-target");
const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME);
if (!raw) return;
ev.preventDefault();
ev.stopPropagation();
await this.handleFeatureDrop(raw, area.name);
});
if (features.length === 0) { if (features.length === 0) {
body.createDiv({ cls: "pk-empty", text: "Keine Features." }); areaCard.createDiv({ cls: "pk-empty", text: "Keine Features." });
return; } else {
} const list = areaCard.createDiv({ cls: "pk-features" });
const list = body.createDiv({ cls: "pk-features" });
for (const f of features) { for (const f of features) {
const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename }); const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename });
chip.draggable = true;
chip.addEventListener("dragstart", (ev) => {
if (!ev.dataTransfer) return;
ev.dataTransfer.setData(
FEATURE_DND_MIME,
JSON.stringify({ sourceArea: area.name, feature: f.basename }),
);
ev.dataTransfer.effectAllowed = "move";
chip.addClass("pk-feature-chip-dragging");
});
chip.addEventListener("dragend", () => {
chip.removeClass("pk-feature-chip-dragging");
});
chip.addEventListener("click", (ev) => { chip.addEventListener("click", (ev) => {
ev.stopPropagation(); ev.stopPropagation();
openMarkdown(this.app, f.path, { openMarkdown(this.app, f.path, {
@@ -203,10 +241,33 @@ export class OverviewView extends ItemView {
]); ]);
}); });
} }
},
});
} }
} }
}
private async handleFeatureDrop(raw: string, targetArea: string): Promise<void> {
let data: { sourceArea: string; feature: string };
try {
data = JSON.parse(raw);
} catch {
return;
}
if (!data?.sourceArea || !data?.feature) return;
if (data.sourceArea === targetArea) return;
const newPath = featurePath(this.project, targetArea, data.feature);
if (this.app.vault.getAbstractFileByPath(newPath)) {
new Notice(`${data.feature}" existiert in „${targetArea}" bereits.`);
return;
}
const oldPath = featurePath(this.project, data.sourceArea, data.feature);
try {
await rename(this.app, oldPath, newPath);
} catch (e) {
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
return;
}
await this.render();
}
private openCreateArea(): void { private openCreateArea(): void {
const taken = listFolders(this.app, projectPath(this.project)).map((a) => a.name); const taken = listFolders(this.app, projectPath(this.project)).map((a) => a.name);

View File

@@ -1,4 +1,4 @@
import { ItemView, Notice, WorkspaceLeaf } from "obsidian"; import { ItemView, Notice, WorkspaceLeaf, normalizePath } from "obsidian";
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON } from "../const"; import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON } from "../const";
import { import {
projectsPath, projectsPath,
@@ -8,8 +8,9 @@ import {
createProject, createProject,
rename, rename,
deleteRecursive, deleteRecursive,
readFile,
} from "../fs"; } from "../fs";
import { card, menu, breadcrumb, emptyState } from "../ui"; import { menu, breadcrumb, emptyState } from "../ui";
import { NameModal } from "../modals/NameModal"; import { NameModal } from "../modals/NameModal";
import { ConfirmModal } from "../modals/ConfirmModal"; import { ConfirmModal } from "../modals/ConfirmModal";
@@ -45,6 +46,11 @@ export class ProjectsView extends ItemView {
this.registerEvent(this.app.vault.on("create", () => this.render())); this.registerEvent(this.app.vault.on("create", () => this.render()));
this.registerEvent(this.app.vault.on("delete", () => this.render())); this.registerEvent(this.app.vault.on("delete", () => this.render()));
this.registerEvent(this.app.vault.on("rename", () => this.render())); this.registerEvent(this.app.vault.on("rename", () => this.render()));
this.registerEvent(this.app.vault.on("modify", (f) => {
if (f.path.endsWith("/core.md") && f.path.startsWith(projectsPath() + "/")) {
this.render();
}
}));
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => { this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
if (ev.defaultPrevented) return; if (ev.defaultPrevented) return;
ev.preventDefault(); ev.preventDefault();
@@ -70,16 +76,29 @@ export class ProjectsView extends ItemView {
} }
const taken = projects.map((p) => p.name); const taken = projects.map((p) => p.name);
const cores = await Promise.all(
projects.map((p) =>
readFile(this.app, normalizePath(`${projectPath(p.name)}/core.md`)),
),
);
const grid = root.createDiv({ cls: "pk-grid" }); const grid = root.createDiv({ cls: "pk-grid" });
for (const proj of projects) { for (let i = 0; i < projects.length; i++) {
card(grid, { const proj = projects[i];
title: proj.name, const core = cores[i].trim();
onClick: () => this.openOverview(proj.name), const btn = grid.createDiv({
onContextMenu: (ev) => cls: "pk-btn-card",
attr: { role: "button", tabindex: "0" },
});
btn.createEl("strong", { text: proj.name });
if (core) btn.createDiv({ text: core });
btn.addEventListener("click", () => this.openOverview(proj.name));
btn.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
menu(ev, [ menu(ev, [
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) }, { title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) },
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) }, { title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) },
]), ]);
}); });
} }
} }

View File

@@ -136,6 +136,37 @@
font-weight: 400; font-weight: 400;
} }
.pk-btn-card {
box-sizing: border-box;
padding: 10px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
cursor: pointer;
overflow-wrap: anywhere;
display: flex;
flex-direction: column;
gap: 6px;
}
.pk-btn-card:hover {
background: var(--background-modifier-hover);
}
.pk-btn-card strong {
font-weight: 600;
}
.pk-drop-target {
outline: 2px dashed var(--text-accent);
outline-offset: -2px;
}
.pk-feature-chip-dragging {
opacity: 0.4;
}
.pk-areas-section { .pk-areas-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;