From 2e8bf9599e64b70cee61cc11c234edb0c0e20f82 Mon Sep 17 00:00:00 2001 From: Team3 Date: Tue, 19 May 2026 22:02:32 +0200 Subject: [PATCH] update --- main.ts | 20 ++- src/const.ts | 8 +- src/fs.ts | 165 +++++++++-------- src/views/CollectionView.ts | 52 +++--- src/views/ProjectDetailsView.ts | 303 ++++++++++++++++++-------------- src/views/ProjectView.ts | 22 +-- styles.css | 6 +- 7 files changed, 317 insertions(+), 259 deletions(-) diff --git a/main.ts b/main.ts index f8f9630..c9876f0 100644 --- a/main.ts +++ b/main.ts @@ -8,7 +8,7 @@ import { import { ProjectView } from "./src/views/ProjectView"; import { ProjectDetailsView } from "./src/views/ProjectDetailsView"; import { CollectionView } from "./src/views/CollectionView"; -import { parseProjectFilePath } from "./src/fs"; +import { parseProjectFilePath, projectPathFromChain } from "./src/fs"; import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui"; export default class ProjektkontextPlugin extends Plugin { @@ -63,7 +63,7 @@ export default class ProjektkontextPlugin extends Plugin { injectMobileBreadcrumb(view, []); return; } - const loc = parseProjectFilePath(file.path); + const loc = parseProjectFilePath(this.app, file.path); if (!loc) { injectMobileBreadcrumb(view, []); return; @@ -73,24 +73,28 @@ export default class ProjektkontextPlugin extends Plugin { label: "Projekte", onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECT_VIEW, active: true }), }, - { - label: loc.project, + ]; + for (let i = 0; i < loc.projectChain.length; i++) { + const projectPath = projectPathFromChain(loc.projectChain.slice(0, i + 1)); + segments.push({ + label: loc.projectChain[i], onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECT_DETAILS_VIEW, active: true, - state: { project: loc.project }, + state: { projectPath }, }), - }, - ]; + }); + } if (loc.collection) { + const projectPath = projectPathFromChain(loc.projectChain); segments.push({ label: loc.collection, onClick: () => void leaf.setViewState({ type: VIEW_TYPE_COLLECTION_VIEW, active: true, - state: { project: loc.project, collection: loc.collection, zone: loc.zone }, + state: { projectPath, collection: loc.collection }, }), }); } diff --git a/src/const.ts b/src/const.ts index 320a92d..3ccbb41 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,7 +1,3 @@ -export const PROJECTS_ROOT = "projects"; -export const TO_UPDATE_DIR = "_to-update"; -export const IDEAS_DIR = "_ideas"; - export const VIEW_TYPE_PROJECT_VIEW = "projektkontext-projects"; export const VIEW_TYPE_PROJECT_DETAILS_VIEW = "projektkontext-overview"; export const VIEW_TYPE_COLLECTION_VIEW = "projektkontext-details"; @@ -9,6 +5,6 @@ export const VIEW_TYPE_COLLECTION_VIEW = "projektkontext-details"; export const RIBBON_ICON = "layout-grid"; export const CORE_FILE = "_core.md"; -export const TARGET_FILE = "_target.md"; +export const DESCRIPTION_FILE = "_description.md"; -export const PROJECT_FILES = [CORE_FILE, TARGET_FILE] as const; +export const PROJECT_FILES = [CORE_FILE, DESCRIPTION_FILE] as const; diff --git a/src/fs.ts b/src/fs.ts index bc4c461..93406c7 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,42 +1,54 @@ import { App, TFile, TFolder, normalizePath } from "obsidian"; -import { PROJECTS_ROOT, PROJECT_FILES, TO_UPDATE_DIR, IDEAS_DIR } from "./const"; +import { PROJECT_FILES, CORE_FILE, DESCRIPTION_FILE } from "./const"; -export type Zone = "ready" | "to-update" | "ideas"; - -export function projectsPath(): string { - return PROJECTS_ROOT; +function hasFile(app: App, folderPath: string, fileName: string): boolean { + const p = normalizePath(`${folderPath}/${fileName}`); + return app.vault.getAbstractFileByPath(p) instanceof TFile; } -export function projectPath(name: string): string { - return normalizePath(`${PROJECTS_ROOT}/${name}`); +export function projectFolder(projectPath: string): string { + return normalizePath(projectPath); } -export function toUpdatePath(project: string): string { - return normalizePath(`${PROJECTS_ROOT}/${project}/${TO_UPDATE_DIR}`); +export function subProjectPath(parent: string, name: string): string { + return normalizePath(`${parent}/${name}`); } -export function ideasPath(project: string): string { - return normalizePath(`${PROJECTS_ROOT}/${project}/${IDEAS_DIR}`); -} - -export function zoneRootPath(project: string, zone: Zone): string { - if (zone === "ready") return projectPath(project); - if (zone === "to-update") return toUpdatePath(project); - return ideasPath(project); -} - -export function collectionPath(project: string, collection: string, zone: Zone = "ready"): string { - return normalizePath(`${zoneRootPath(project, zone)}/${collection}`); +export function collectionPath(projectPath: string, collection: string): string { + return normalizePath(`${projectPath}/${collection}`); } export function featurePath( - project: string, + projectPath: string, collection: string, feature: string, - zone: Zone = "ready", ): string { const file = feature.endsWith(".md") ? feature : `${feature}.md`; - return normalizePath(`${zoneRootPath(project, zone)}/${collection}/${file}`); + return normalizePath(`${projectPath}/${collection}/${file}`); +} + +export function projectFeaturePath(projectPath: string, feature: string): string { + const file = feature.endsWith(".md") ? feature : `${feature}.md`; + return normalizePath(`${projectPath}/${file}`); +} + +export function hasCore(app: App, folderPath: string): boolean { + return hasFile(app, folderPath, CORE_FILE); +} + +export function hasDescription(app: App, folderPath: string): boolean { + return hasFile(app, folderPath, DESCRIPTION_FILE); +} + +export function hasMarker(app: App, folderPath: string): boolean { + return hasCore(app, folderPath) || hasDescription(app, folderPath); +} + +export function isCollectionFolder(app: App, folderPath: string): boolean { + const parts = normalizePath(folderPath).split("/"); + if (parts.length < 2) return false; + const parent = parts.slice(0, -1).join("/"); + return hasMarker(app, parent) && !hasMarker(app, folderPath); } export async function ensureFolder(app: App, path: string): Promise { @@ -54,16 +66,32 @@ export async function ensureFile(app: App, path: string, content = ""): Promise< return await app.vault.create(p, content); } -export function listFolders(app: App, path: string): TFolder[] { - const p = normalizePath(path); - const folder = app.vault.getAbstractFileByPath(p); - if (!(folder instanceof TFolder)) return []; +function visibleFolders(folder: TFolder): TFolder[] { return folder.children .filter((c): c is TFolder => c instanceof TFolder) .filter((c) => !c.name.startsWith("_") || c.name.startsWith("__")) .sort((a, b) => a.name.localeCompare(b.name)); } +export function listFolders(app: App, path: string): TFolder[] { + const folder = app.vault.getAbstractFileByPath(normalizePath(path)); + if (!(folder instanceof TFolder)) return []; + return visibleFolders(folder); +} + +export function listTopLevelProjects(app: App): TFolder[] { + const root = app.vault.getRoot(); + return visibleFolders(root); +} + +export function listSubProjects(app: App, projectPath: string): TFolder[] { + return listFolders(app, projectPath).filter((f) => !isCollectionFolder(app, f.path)); +} + +export function listCollections(app: App, projectPath: string): TFolder[] { + return listFolders(app, projectPath).filter((f) => isCollectionFolder(app, f.path)); +} + export function listMarkdownFiles(app: App, path: string, exclude: string[] = []): TFile[] { const p = normalizePath(path); const folder = app.vault.getAbstractFileByPath(p); @@ -75,6 +103,10 @@ export function listMarkdownFiles(app: App, path: string, exclude: string[] = [] .sort((a, b) => a.basename.localeCompare(b.basename)); } +export function listProjectFeatures(app: App, projectPath: string): TFile[] { + return listMarkdownFiles(app, projectPath, [...PROJECT_FILES]); +} + export async function readFile(app: App, path: string): Promise { const p = normalizePath(path); const f = app.vault.getAbstractFileByPath(p); @@ -95,77 +127,66 @@ export async function rename(app: App, oldPath: string, newPath: string): Promis await app.fileManager.renameFile(f, normalizePath(newPath)); } -export async function createProject(app: App, name: string): Promise { - const root = projectPath(name); - await ensureFolder(app, projectsPath()); +export async function createProject(app: App, projectPath: string): Promise { + const root = normalizePath(projectPath); await ensureFolder(app, root); - for (const file of PROJECT_FILES) { - await ensureFile(app, normalizePath(`${root}/${file}`), ""); - } - await createCollection(app, name, "story"); + await ensureFile(app, normalizePath(`${root}/${CORE_FILE}`), ""); + await ensureFile(app, normalizePath(`${root}/${DESCRIPTION_FILE}`), ""); } export async function createCollection( app: App, - project: string, + projectPath: string, collection: string, - zone: Zone = "to-update", ): Promise { - if (zone !== "ready") await ensureFolder(app, zoneRootPath(project, zone)); - await ensureFolder(app, collectionPath(project, collection, zone)); + await ensureFolder(app, collectionPath(projectPath, collection)); } export async function createFeature( app: App, - project: string, + projectPath: string, collection: string, feature: string, - zone: Zone = "ready", ): Promise { - return await ensureFile(app, featurePath(project, collection, feature, zone), ""); -} - -export function projectFeaturePath( - project: string, - feature: string, - zone: Zone = "ready", -): string { - const file = feature.endsWith(".md") ? feature : `${feature}.md`; - return normalizePath(`${zoneRootPath(project, zone)}/${file}`); + return await ensureFile(app, featurePath(projectPath, collection, feature), ""); } export async function createProjectFeature( app: App, - project: string, + projectPath: string, feature: string, - zone: Zone = "to-update", ): Promise { - if (zone !== "ready") await ensureFolder(app, zoneRootPath(project, zone)); - return await ensureFile(app, projectFeaturePath(project, feature, zone), ""); + return await ensureFile(app, projectFeaturePath(projectPath, feature), ""); } export interface ProjectFileLocation { - project: string; + projectChain: string[]; collection?: string; - zone: Zone; } -export function parseProjectFilePath(path: string): ProjectFileLocation | null { +export function parseProjectFilePath(app: App, path: string): ProjectFileLocation | null { if (!path.endsWith(".md")) return null; const parts = normalizePath(path).split("/"); - if (parts[0] !== PROJECTS_ROOT) return null; - if (parts.length < 3) return null; - const project = parts[1]; - const rest = parts.slice(2); - let zone: Zone = "ready"; - if (rest[0] === TO_UPDATE_DIR) { - zone = "to-update"; - rest.shift(); - } else if (rest[0] === IDEAS_DIR) { - zone = "ideas"; - rest.shift(); + if (parts.length < 2) return null; + const segments = parts.slice(0, -1); + if (segments.length === 0) return null; + + const projectChain: string[] = [segments[0]]; + let collection: string | undefined; + + for (let i = 1; i < segments.length; i++) { + const prefix = segments.slice(0, i + 1).join("/"); + if (isCollectionFolder(app, prefix)) { + if (i !== segments.length - 1) return null; + collection = segments[i]; + } else { + projectChain.push(segments[i]); + } } - if (rest.length === 1) return { project, zone }; - if (rest.length === 2) return { project, collection: rest[0], zone }; - return null; + + return { projectChain, collection }; +} + +export function projectPathFromChain(chain: string[]): string { + return normalizePath(chain.join("/")); } diff --git a/src/views/CollectionView.ts b/src/views/CollectionView.ts index 860346e..cea6356 100644 --- a/src/views/CollectionView.ts +++ b/src/views/CollectionView.ts @@ -6,7 +6,6 @@ import { RIBBON_ICON, } from "../const"; import { - Zone, collectionPath, featurePath, listMarkdownFiles, @@ -14,13 +13,12 @@ import { createFeature, deleteRecursive, } from "../fs"; -import { menu, breadcrumb, emptyState, openMarkdown } from "../ui"; +import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui"; import { NameModal } from "../modals/NameModal"; export interface CollectionViewState extends Record { - project: string; + projectPath: string; collection: string; - zone?: Zone; } const NAME_RX = /^[^\\/:*?"<>|]+$/; @@ -33,9 +31,8 @@ function validateName(name: string, taken: string[]): string | null { } export class CollectionView extends ItemView { - project = ""; + projectPath = ""; collection = ""; - zone: Zone = "ready"; private renderToken = 0; constructor(leaf: WorkspaceLeaf) { @@ -56,15 +53,14 @@ export class CollectionView extends ItemView { } async setState(state: CollectionViewState, result: ViewStateResult): Promise { - this.project = state?.project ?? ""; - this.collection = state?.collection ?? (state as { area?: string })?.area ?? ""; - this.zone = state?.zone ?? "ready"; + this.projectPath = state?.projectPath ?? ""; + this.collection = state?.collection ?? ""; await super.setState(state, result); await this.render(); } getState(): CollectionViewState { - return { project: this.project, collection: this.collection, zone: this.zone }; + return { projectPath: this.projectPath, collection: this.collection }; } async onOpen(): Promise { @@ -87,7 +83,7 @@ export class CollectionView extends ItemView { const token = ++this.renderToken; const root = this.containerEl.children[1] as HTMLElement; - if (!this.project || !this.collection) { + if (!this.projectPath || !this.collection) { root.empty(); root.addClass("pk-root"); emptyState(root, "Keine Collection ausgewählt."); @@ -96,7 +92,7 @@ export class CollectionView extends ItemView { const features = listMarkdownFiles( this.app, - collectionPath(this.project, this.collection, this.zone), + collectionPath(this.projectPath, this.collection), [], ); const contents = await Promise.all(features.map((f) => readFile(this.app, f.path))); @@ -105,11 +101,7 @@ export class CollectionView extends ItemView { root.empty(); root.addClass("pk-root"); - breadcrumb(root, [ - { label: "Projekte", onClick: () => this.openProjectView() }, - { label: this.project, onClick: () => this.openProjectDetails() }, - { label: this.collection }, - ]); + this.renderBreadcrumb(root); if (features.length === 0) { emptyState(root, "Keine Features"); @@ -141,10 +133,26 @@ export class CollectionView extends ItemView { } } + private renderBreadcrumb(parent: HTMLElement): void { + const chain = this.projectPath.split("/"); + const segments: BreadcrumbSegment[] = [ + { label: "Projekte", onClick: () => this.openProjectView() }, + ]; + for (let i = 0; i < chain.length; i++) { + const path = chain.slice(0, i + 1).join("/"); + segments.push({ + label: chain[i], + onClick: () => this.openProjectDetails(path), + }); + } + segments.push({ label: this.collection }); + breadcrumb(parent, segments); + } + private openCreate(): void { const taken = listMarkdownFiles( this.app, - collectionPath(this.project, this.collection, this.zone), + collectionPath(this.projectPath, this.collection), [], ).map((f) => f.basename); new NameModal(this.app, { @@ -153,7 +161,7 @@ export class CollectionView extends ItemView { cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { - await createFeature(this.app, this.project, this.collection, name, this.zone); + await createFeature(this.app, this.projectPath, this.collection, name); await this.render(); }, }).open(); @@ -162,16 +170,16 @@ export class CollectionView extends ItemView { private async openDelete(feature: string): Promise { await deleteRecursive( this.app, - featurePath(this.project, this.collection, feature, this.zone), + featurePath(this.projectPath, this.collection, feature), ); await this.render(); } - private async openProjectDetails(): Promise { + private async openProjectDetails(projectPath: string): Promise { await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_DETAILS_VIEW, active: true, - state: { project: this.project }, + state: { projectPath }, }); } diff --git a/src/views/ProjectDetailsView.ts b/src/views/ProjectDetailsView.ts index 66d3e68..65dcc04 100644 --- a/src/views/ProjectDetailsView.ts +++ b/src/views/ProjectDetailsView.ts @@ -13,35 +13,36 @@ import { VIEW_TYPE_COLLECTION_VIEW, VIEW_TYPE_PROJECT_VIEW, RIBBON_ICON, - PROJECT_FILES, CORE_FILE, - TARGET_FILE, + DESCRIPTION_FILE, } from "../const"; import { - Zone, - projectPath, - zoneRootPath, - listFolders, + projectFolder, + subProjectPath, + collectionPath, + listSubProjects, + listCollections, + listProjectFeatures, listMarkdownFiles, readFile, rename, deleteRecursive, - ensureFolder, createCollection, + createProject, createProjectFeature, } from "../fs"; -import { menu, breadcrumb, emptyState, openMarkdown } from "../ui"; +import { menu, breadcrumb, emptyState, openMarkdown, BreadcrumbSegment } from "../ui"; import { NameModal } from "../modals/NameModal"; export interface ProjectDetailsState extends Record { - project: string; + projectPath: string; } const NAME_RX = /^[^\\/:*?"<>|]+$/; const PK_DND_MIME = "application/x-pk-item"; interface DndPayload { - kind: "feature" | "collection"; + kind: "feature"; sourcePath: string; name: string; } @@ -54,7 +55,7 @@ function validateName(name: string, taken: string[], current?: string): string | } export class ProjectDetailsView extends ItemView { - project = ""; + projectPath = ""; private renderToken = 0; constructor(leaf: WorkspaceLeaf) { @@ -67,7 +68,9 @@ export class ProjectDetailsView extends ItemView { } getDisplayText(): string { - return this.project ? `Projekt: ${this.project}` : "Projekt"; + if (!this.projectPath) return "Projekt"; + const segs = this.projectPath.split("/"); + return `Projekt: ${segs[segs.length - 1]}`; } getIcon(): string { @@ -75,13 +78,13 @@ export class ProjectDetailsView extends ItemView { } async setState(state: ProjectDetailsState, result: ViewStateResult): Promise { - this.project = state?.project ?? ""; + this.projectPath = state?.projectPath ?? ""; await super.setState(state, result); await this.render(); } getState(): ProjectDetailsState { - return { project: this.project }; + return { projectPath: this.projectPath }; } async onOpen(): Promise { @@ -90,7 +93,7 @@ export class ProjectDetailsView extends ItemView { this.registerEvent(this.app.vault.on("delete", () => this.render())); this.registerEvent(this.app.vault.on("rename", () => this.render())); this.registerEvent(this.app.vault.on("modify", (f) => { - if (f.path.startsWith(projectPath(this.project) + "/")) this.render(); + if (this.projectPath && f.path.startsWith(this.projectPath + "/")) this.render(); })); } @@ -100,34 +103,55 @@ export class ProjectDetailsView extends ItemView { const token = ++this.renderToken; const root = this.containerEl.children[1] as HTMLElement; - if (!this.project) { + if (!this.projectPath) { root.empty(); root.addClass("pk-root"); emptyState(root, "Kein Projekt ausgewählt."); return; } - const projRoot = projectPath(this.project); + const projRoot = projectFolder(this.projectPath); const corePath = normalizePath(`${projRoot}/${CORE_FILE}`); - const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`); - const [core, target] = await Promise.all([ + const descPath = normalizePath(`${projRoot}/${DESCRIPTION_FILE}`); + const subProjectsList = listSubProjects(this.app, this.projectPath); + const subCorePaths = subProjectsList.map((s) => normalizePath(`${s.path}/${CORE_FILE}`)); + const [core, desc, ...subCores] = await Promise.all([ readFile(this.app, corePath), - readFile(this.app, targetPath), + readFile(this.app, descPath), + ...subCorePaths.map((p) => readFile(this.app, p)), ]); if (token !== this.renderToken) return; root.empty(); root.addClass("pk-root"); - breadcrumb(root, [ - { label: "Projekte", onClick: () => this.openProjectView() }, - { label: this.project }, - ]); + this.renderBreadcrumb(root); - const info = root.createDiv({ cls: "pk-info-grid" }); - this.renderInfoCard(info, core, corePath); - this.renderInfoCard(info, target, targetPath); - this.renderCollections(root); + const infoCards: Array<{ content: string; path: string }> = []; + if (core.trim()) infoCards.push({ content: core, path: corePath }); + if (desc.trim()) infoCards.push({ content: desc, path: descPath }); + if (infoCards.length > 0) { + const info = root.createDiv({ cls: "pk-info-grid" }); + for (const ic of infoCards) this.renderInfoCard(info, ic.content, ic.path); + } + + this.renderChildren(root, subProjectsList, subCorePaths, subCores); + } + + private renderBreadcrumb(parent: HTMLElement): void { + const chain = this.projectPath.split("/"); + const segments: BreadcrumbSegment[] = [ + { label: "Projekte", onClick: () => this.openProjectView() }, + ]; + for (let i = 0; i < chain.length; i++) { + const path = chain.slice(0, i + 1).join("/"); + const isLast = i === chain.length - 1; + segments.push({ + label: chain[i], + onClick: isLast ? undefined : () => this.openProjectDetails(path), + }); + } + breadcrumb(parent, segments); } private renderInfoCard(parent: HTMLElement, content: string, path: string): void { @@ -136,60 +160,25 @@ export class ProjectDetailsView extends ItemView { attr: { role: "button", tabindex: "0" }, }); btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf)); - if (content.trim()) { - void MarkdownRenderer.render(this.app, content, btn, path, this); - } else { - btn.setText("(leer)"); - } + void MarkdownRenderer.render(this.app, content, btn, path, this); } - private renderCollections(parent: HTMLElement): void { - const section = parent.createDiv({ cls: "pk-areas-section" }); - - const ready = this.collectZoneItems("ready"); - const toUpdate = this.collectZoneItems("to-update"); - const ideas = this.collectZoneItems("ideas"); - - this.renderZone(section, "ready", ready); - section.createEl("hr", { cls: "pk-zone-divider" }); - this.renderZone(section, "to-update", toUpdate); - section.createEl("hr", { cls: "pk-zone-divider" }); - this.renderZone(section, "ideas", ideas); - } - - private collectZoneItems(zone: Zone): { collections: TFolder[]; features: TFile[] } { - const root = zoneRootPath(this.project, zone); - const folders = listFolders(this.app, root); - const features = listMarkdownFiles( - this.app, - root, - zone === "ready" ? [...PROJECT_FILES] : [], - ); - return { collections: folders, features }; - } - - private zoneEmptyText(zone: Zone): string { - if (zone === "ready") return "Keine fertigen Features"; - if (zone === "to-update") return "Keine unfertigen Features"; - return "Keine neuen Features"; - } - - private renderZone( + private renderChildren( parent: HTMLElement, - zone: Zone, - items: { collections: TFolder[]; features: TFile[] }, + subProjects: TFolder[], + subCorePaths: string[], + subCores: string[], ): void { - const flex = parent.createDiv({ - cls: `pk-areas-flex pk-zone-${zone}`, - attr: { "data-zone": zone }, - }); + const collections = listCollections(this.app, this.projectPath); + const features = listProjectFeatures(this.app, this.projectPath); + + const section = parent.createDiv({ cls: "pk-areas-section" }); + const flex = section.createDiv({ cls: "pk-areas-flex" }); + flex.addEventListener("contextmenu", (ev) => { if (ev.defaultPrevented) return; ev.preventDefault(); - menu(ev, [ - { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection(zone) }, - { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) }, - ]); + this.openProjectLevelMenu(ev); }); flex.addEventListener("dragover", (ev) => { if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; @@ -207,56 +196,88 @@ export class ProjectDetailsView extends ItemView { const raw = ev.dataTransfer?.getData(PK_DND_MIME); if (!raw) return; ev.preventDefault(); - await this.handleDropOnZone(raw, zone); + await this.handleDropOnProjectRoot(raw); }); - const takenCollections = items.collections.map((a) => a.name); - for (const collection of items.collections) { - this.renderCollectionCard(flex, zone, collection, takenCollections); + const takenChildren = [ + ...subProjects.map((f) => f.name), + ...collections.map((f) => f.name), + ]; + + for (let i = 0; i < subProjects.length; i++) { + this.renderSubProjectCard(flex, subProjects[i], takenChildren, subCores[i] ?? "", subCorePaths[i] ?? ""); } - for (const f of items.features) { - this.renderProjectFeatureCard(flex, zone, f); + for (const col of collections) { + this.renderCollectionCard(flex, col, takenChildren); } - if (items.collections.length === 0 && items.features.length === 0) { - flex.createDiv({ cls: "pk-zone-placeholder", text: this.zoneEmptyText(zone) }); + for (const f of features) { + this.renderProjectFeatureCard(flex, f); } + if (subProjects.length === 0 && collections.length === 0 && features.length === 0) { + flex.createDiv({ cls: "pk-zone-placeholder", text: "Keine Inhalte. Rechtsklick fügt Sub-Projekt, Collection oder Feature hinzu." }); + } + } + + private openProjectLevelMenu(ev: MouseEvent): void { + menu(ev, [ + { title: "Neues Sub-Projekt", icon: "plus", onClick: () => this.openCreateSubProject() }, + { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() }, + { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() }, + ]); + } + + private renderSubProjectCard( + parent: HTMLElement, + sub: TFolder, + takenChildren: string[], + coreContent: string, + corePath: string, + ): void { + const card = parent.createDiv({ + cls: "pk-btn-card pk-area-card", + attr: { role: "button", tabindex: "0" }, + }); + card.createEl("strong", { text: sub.name }); + const trimmed = coreContent.trim(); + if (trimmed) { + const body = card.createDiv({ cls: "pk-project-core" }); + void MarkdownRenderer.render(this.app, coreContent, body, corePath, this); + } + card.addEventListener("click", () => this.openProjectDetails(sub.path)); + card.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + menu(ev, [ + { title: "Neues Sub-Projekt", icon: "plus", onClick: () => this.openCreateSubProject() }, + { title: "Sub-Projekt umbenennen", icon: "pencil", onClick: () => this.openRenameChildFolder(sub, takenChildren) }, + { title: "Sub-Projekt löschen", icon: "trash", onClick: () => this.openDeletePath(sub.path) }, + ]); + }); } private renderCollectionCard( parent: HTMLElement, - zone: Zone, collection: TFolder, - takenCollections: string[], + takenChildren: string[], ): void { const folderPath = collection.path; const features = listMarkdownFiles(this.app, folderPath, []); const card = parent.createDiv({ cls: "pk-btn-card pk-area-card", - attr: { role: "button", tabindex: "0", draggable: "true" }, + attr: { role: "button", tabindex: "0" }, }); card.createEl("strong", { text: collection.name }); - card.addEventListener("click", () => this.openCollectionDetails(collection.name, zone)); + card.addEventListener("click", () => this.openCollectionDetails(collection.name)); card.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); menu(ev, [ - { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection(zone) }, - { title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameCollectionAt(folderPath, collection.name, takenCollections) }, + { title: "Neue Collection", icon: "plus", onClick: () => this.openCreateCollection() }, + { title: "Collection umbenennen", icon: "pencil", onClick: () => this.openRenameChildFolder(collection, takenChildren) }, { title: "Collection löschen", icon: "trash", onClick: () => this.openDeletePath(folderPath) }, ]); }); - card.addEventListener("dragstart", (ev) => { - if (!ev.dataTransfer) return; - ev.dataTransfer.setData(PK_DND_MIME, JSON.stringify({ - kind: "collection", - sourcePath: folderPath, - name: collection.name, - } satisfies DndPayload)); - ev.dataTransfer.effectAllowed = "move"; - card.addClass("pk-feature-chip-dragging"); - }); - card.addEventListener("dragend", () => card.removeClass("pk-feature-chip-dragging")); card.addEventListener("dragover", (ev) => { if (!ev.dataTransfer?.types.includes(PK_DND_MIME)) return; ev.preventDefault(); @@ -312,7 +333,7 @@ export class ProjectDetailsView extends ItemView { } } - private renderProjectFeatureCard(parent: HTMLElement, zone: Zone, file: TFile): void { + private renderProjectFeatureCard(parent: HTMLElement, file: TFile): void { const card = parent.createDiv({ cls: "pk-btn-card pk-area-card", attr: { role: "button", tabindex: "0", draggable: "true" }, @@ -334,7 +355,7 @@ export class ProjectDetailsView extends ItemView { ev.preventDefault(); ev.stopPropagation(); menu(ev, [ - { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature(zone) }, + { title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() }, { title: "Feature löschen", icon: "trash", onClick: () => this.openDeletePath(file.path) }, ]); }); @@ -344,30 +365,25 @@ export class ProjectDetailsView extends ItemView { try { const data = JSON.parse(raw); if (!data?.sourcePath || !data?.name) return null; - if (data.kind !== "feature" && data.kind !== "collection") return null; + if (data.kind !== "feature") return null; return data as DndPayload; } catch { return null; } } - private async handleDropOnZone(raw: string, zone: Zone): Promise { + private async handleDropOnProjectRoot(raw: string): Promise { const data = this.parseDnd(raw); if (!data) return; - const root = zoneRootPath(this.project, zone); - const targetName = data.kind === "feature" - ? (data.name.endsWith(".md") ? data.name : `${data.name}.md`) - : data.name; - const newPath = normalizePath(`${root}/${targetName}`); + const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`; + const newPath = normalizePath(`${this.projectPath}/${file}`); if (data.sourcePath === newPath) return; - if (zone !== "ready") await ensureFolder(this.app, root); await this.movePath(data.sourcePath, newPath); } private async handleDropOnCollection(raw: string, collectionFolderPath: string): Promise { const data = this.parseDnd(raw); if (!data) return; - if (data.kind !== "feature") return; const file = data.name.endsWith(".md") ? data.name : `${data.name}.md`; const newPath = normalizePath(`${collectionFolderPath}/${file}`); if (data.sourcePath === newPath) return; @@ -388,38 +404,49 @@ export class ProjectDetailsView extends ItemView { await this.render(); } - private openCreateProjectFeature(zone: Zone = "to-update"): void { - const root = zoneRootPath(this.project, zone); - const existing = listMarkdownFiles( - this.app, - root, - zone === "ready" ? [...PROJECT_FILES] : [], - ); - const taken = existing.map((f) => f.basename); + private openCreateProjectFeature(): void { + const taken = listProjectFeatures(this.app, this.projectPath).map((f) => f.basename); new NameModal(this.app, { title: "Neues Feature", label: "Feature-Name", cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { - await createProjectFeature(this.app, this.project, name, zone); + await createProjectFeature(this.app, this.projectPath, name); await this.render(); }, }).open(); } - private openCreateCollection(zone: Zone = "to-update"): void { - const allZones: Zone[] = ["ready", "to-update", "ideas"]; - const taken = allZones.flatMap((z) => - listFolders(this.app, zoneRootPath(this.project, z)).map((a) => a.name), - ); + private openCreateCollection(): void { + const taken = [ + ...listCollections(this.app, this.projectPath).map((f) => f.name), + ...listSubProjects(this.app, this.projectPath).map((f) => f.name), + ]; new NameModal(this.app, { title: "Neue Collection", label: "Collection-Name", cta: "Erstellen", validate: (n) => validateName(n, taken), onSubmit: async (name) => { - await createCollection(this.app, this.project, name, zone); + await createCollection(this.app, this.projectPath, name); + await this.render(); + }, + }).open(); + } + + private openCreateSubProject(): void { + const taken = [ + ...listSubProjects(this.app, this.projectPath).map((f) => f.name), + ...listCollections(this.app, this.projectPath).map((f) => f.name), + ]; + new NameModal(this.app, { + title: "Neues Sub-Projekt", + label: "Projektname", + cta: "Erstellen", + validate: (n) => validateName(n, taken), + onSubmit: async (name) => { + await createProject(this.app, subProjectPath(this.projectPath, name)); await this.render(); }, }).open(); @@ -442,17 +469,19 @@ export class ProjectDetailsView extends ItemView { }).open(); } - private openRenameCollectionAt(folderPath: string, current: string, taken: string[]): void { + private openRenameChildFolder(folder: TFolder, taken: string[]): void { + const current = folder.name; new NameModal(this.app, { - title: "Collection umbenennen", - label: "Collection-Name", + title: "Umbenennen", + label: "Name", initial: current, cta: "Speichern", validate: (n) => validateName(n, taken, current), onSubmit: async (name) => { if (name === current) return; - const parent = folderPath.substring(0, folderPath.lastIndexOf("/")); - await rename(this.app, folderPath, normalizePath(`${parent}/${name}`)); + const parent = folder.path.substring(0, folder.path.lastIndexOf("/")); + const newPath = parent ? normalizePath(`${parent}/${name}`) : normalizePath(name); + await rename(this.app, folder.path, newPath); await this.render(); }, }).open(); @@ -463,11 +492,19 @@ export class ProjectDetailsView extends ItemView { await this.render(); } - private async openCollectionDetails(collection: string, zone: Zone): Promise { + private async openCollectionDetails(collection: string): Promise { await this.leaf.setViewState({ type: VIEW_TYPE_COLLECTION_VIEW, active: true, - state: { project: this.project, collection, zone }, + state: { projectPath: this.projectPath, collection }, + }); + } + + private async openProjectDetails(projectPath: string): Promise { + await this.leaf.setViewState({ + type: VIEW_TYPE_PROJECT_DETAILS_VIEW, + active: true, + state: { projectPath }, }); } diff --git a/src/views/ProjectView.ts b/src/views/ProjectView.ts index a65c669..bf20752 100644 --- a/src/views/ProjectView.ts +++ b/src/views/ProjectView.ts @@ -12,10 +12,7 @@ import { CORE_FILE, } from "../const"; import { - projectsPath, - projectPath, - listFolders, - ensureFolder, + listTopLevelProjects, createProject, rename, deleteRecursive, @@ -54,15 +51,12 @@ export class ProjectView extends ItemView { } async onOpen(): Promise { - await ensureFolder(this.app, projectsPath()); await 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("rename", () => this.render())); this.registerEvent(this.app.vault.on("modify", (f) => { - if (f.path.endsWith("/" + CORE_FILE) && f.path.startsWith(projectsPath() + "/")) { - this.render(); - } + if (f.path.endsWith("/" + CORE_FILE)) this.render(); })); this.registerDomEvent(this.containerEl, "contextmenu", (ev) => { if (ev.defaultPrevented) return; @@ -79,7 +73,7 @@ export class ProjectView extends ItemView { const token = ++this.renderToken; const root = this.containerEl.children[1] as HTMLElement; - const projects = listFolders(this.app, projectsPath()); + const projects = listTopLevelProjects(this.app); if (projects.length === 0) { if (token !== this.renderToken) return; root.empty(); @@ -90,7 +84,7 @@ export class ProjectView extends ItemView { } const taken = projects.map((p) => p.name); - const corePaths = projects.map((p) => normalizePath(`${projectPath(p.name)}/${CORE_FILE}`)); + const corePaths = projects.map((p) => normalizePath(`${p.path}/${CORE_FILE}`)); const cores = await Promise.all(corePaths.map((p) => readFile(this.app, p))); if (token !== this.renderToken) return; @@ -124,7 +118,7 @@ export class ProjectView extends ItemView { } private openCreate(): void { - const taken = listFolders(this.app, projectsPath()).map((p) => p.name); + const taken = listTopLevelProjects(this.app).map((p) => p.name); new NameModal(this.app, { title: "Neues Projekt", label: "Projektname", @@ -152,7 +146,7 @@ export class ProjectView extends ItemView { onSubmit: async (name) => { if (name === current) return; try { - await rename(this.app, projectPath(current), projectPath(name)); + await rename(this.app, current, name); await this.render(); } catch (e) { new Notice(`Fehler: ${(e as Error).message}`); @@ -163,7 +157,7 @@ export class ProjectView extends ItemView { private async openDelete(name: string): Promise { try { - await deleteRecursive(this.app, projectPath(name)); + await deleteRecursive(this.app, name); await this.render(); } catch (e) { new Notice(`Fehler: ${(e as Error).message}`); @@ -174,7 +168,7 @@ export class ProjectView extends ItemView { await this.leaf.setViewState({ type: VIEW_TYPE_PROJECT_DETAILS_VIEW, active: true, - state: { project: name }, + state: { projectPath: name }, }); } } diff --git a/styles.css b/styles.css index aa125e4..00586a0 100644 --- a/styles.css +++ b/styles.css @@ -257,8 +257,6 @@ margin-bottom: 5px; } -.pk-zone-divider { - border: 0; - border-top: 1px solid var(--background-modifier-border); - margin: 4px 0; +.pk-sub-project-card { + border-left: 3px solid var(--text-accent); }