diff --git a/doc/__/desktop.md b/doc/__/desktop.md new file mode 100644 index 0000000..371d4bf --- /dev/null +++ b/doc/__/desktop.md @@ -0,0 +1,9 @@ +- links sind die übersichten + - projektübersicht + - projektdetails + - areadetails + - links bleibt im selben fenster +- rechts sind die md dateien +- md datei öffnen + - öffnen sich immer rechts beim nächstmöglichen fenster + - nicht vorhanden, dann wird ein neues geöffnet diff --git a/doc/__/mobile.md b/doc/__/mobile.md new file mode 100644 index 0000000..4706149 --- /dev/null +++ b/doc/__/mobile.md @@ -0,0 +1,2 @@ +- projektübersicht, projektdetails, areadetails und md dateien bleiben im selben fenster +- navigation über die breadcrumb diff --git a/doc/_core.md b/doc/_core.md index 68c71ec..7a612aa 100644 --- a/doc/_core.md +++ b/doc/_core.md @@ -1 +1,2 @@ -Obsidian Plugin - Projektkontext managen \ No newline at end of file +**Obsidian Plugin** +Projektkontext managen \ No newline at end of file diff --git a/doc/_project.md b/doc/_project.md deleted file mode 100644 index 82e9666..0000000 --- a/doc/_project.md +++ /dev/null @@ -1,24 +0,0 @@ -**Core** -- Obsidian Plugin - Projektkontext managen -**Target** -- Für große Projekte geht der Kontext schnell verloren. -- Sie werden unübersichtlich und schwer zu managen. -- Das Plugin hilft Projekte kontextbezogen zu strukturieren. -- So lassen sich große Projekte wieder managen. -**Story** -- Großes Projekt soll geplannt werden -- Core definieren (Worum geht es?) -- Target beschreiben (Welches Problem löst es?) -- Story schreiben (Wie wird es verwendet?) -- Areas und Features definieren (Welche Funktionen hat es?) -- Features implementieren -- Projekt v1 -**Areas** -- **Core**: Kompakter Titel zum Projekt -- **Target**: Problem -> Folge -> Lösung -> Ergebnis zum Projekt -- **Story**: Kernverwendung vom Projekt -- **Area**: Sammlung von Features -- **Feature**: Abgeschlossene Funktion im Bereich -- **Projects**: Übersicht der Projekte -- **Overview**: Übersicht zu einem Projekt -- **Detail**: Übersicht zu einem Area diff --git a/doc/allgemein.md b/doc/allgemein.md deleted file mode 100644 index 0a111ca..0000000 --- a/doc/allgemein.md +++ /dev/null @@ -1,8 +0,0 @@ -Desktop -- links projektübersicht, projektdetails und areadetails (je nachdem was auf ist) -- rechts md dateien - -Mobile -- projektübersicht, projektdetails und areadetails -- md dateien werden in der gleichen sicht geöffnet -- wischen nach rechts schließt sie und zeigt wieder die übersicht diff --git a/doc/area.md b/doc/area.md index 3eaea46..cff17eb 100644 --- a/doc/area.md +++ b/doc/area.md @@ -1,3 +1,4 @@ -- container für features -- Auflistung als Columns +- sammlung von features +- Auflistung als Columns (aufstockend) - wird gespeichert als `./{area}/` +- `__` sind allgemeine zum ordner diff --git a/doc/breadcrumb.md b/doc/breadcrumb.md new file mode 100644 index 0000000..a4137e3 --- /dev/null +++ b/doc/breadcrumb.md @@ -0,0 +1,7 @@ +- steht ganz oben +- desktop + - Projekte > {projektname} + - Projekte > {projektname} > {areaname} +- mobile + - Projekte > {projektname} > {md-name} + - Projekte > {projektname} > {areaname} > {md-name} diff --git a/doc/core.md b/doc/core.md index b4c2d0f..ff9c001 100644 --- a/doc/core.md +++ b/doc/core.md @@ -1,3 +1,4 @@ -- Max 64 Zeichen -- wird als `./_core.md` gespeichert - beschreibt kompakt was das projekt ist +- Max 128 Zeichen +- wird als `./_core.md` gespeichert +- als md gerendert diff --git a/doc/details.md b/doc/details.md deleted file mode 100644 index e39568c..0000000 --- a/doc/details.md +++ /dev/null @@ -1,17 +0,0 @@ -- LMB auf area öffnet die areatdetails -- enthält details zur area - -layout: features - -features -- alle features zum area als cards -- LMB auf feature öffnet die `{feature}.md` -- RMB auf feature öffnet die feature optionen - -feature option create -- erstellt eine neue feature md datei im area ordner -- modal für den feature namen -- erstellen und abbrechen buttons - -feature option delete -- entfernt feature diff --git a/doc/details/__.md b/doc/details/__.md new file mode 100644 index 0000000..8de98d9 --- /dev/null +++ b/doc/details/__.md @@ -0,0 +1,3 @@ +- LMB auf area öffnet die areatdetails +- enthält details zur area +- layout: features \ No newline at end of file diff --git a/doc/details/createFeature.md b/doc/details/createFeature.md new file mode 100644 index 0000000..78f075a --- /dev/null +++ b/doc/details/createFeature.md @@ -0,0 +1,3 @@ +- erstellt eine neue feature md datei im area ordner +- modal für den feature namen +- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/details/deleteFeature.md b/doc/details/deleteFeature.md new file mode 100644 index 0000000..78f075a --- /dev/null +++ b/doc/details/deleteFeature.md @@ -0,0 +1,3 @@ +- erstellt eine neue feature md datei im area ordner +- modal für den feature namen +- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/details/features.md b/doc/details/features.md new file mode 100644 index 0000000..de0d46d --- /dev/null +++ b/doc/details/features.md @@ -0,0 +1,9 @@ +- alle features zum area als button-cards + - name ind bold + - inhalt als md gerendert dadrunter +- max width für cards ist hier 300px +- LMB auf feature öffnet die `{feature}.md` +- RMB auf feature container öffnet die feature optionen + - feature erstellen +- RMB auf feature öffnet die feature optionen + - feature löschen \ No newline at end of file diff --git a/doc/overview.md b/doc/overview.md deleted file mode 100644 index 7ed6350..0000000 --- a/doc/overview.md +++ /dev/null @@ -1,54 +0,0 @@ -- LMB auf projekt öffnet die projektdetails -- enthält details zum projekt - -layout -- core -- target -- story -- areas - -core -- inhalt aus core.md -- oben als card -- LMB öffnet ./core.md - -target -- inhalt aus ./target.md -- unter kern als card -- LMB öffnet target.md - -story -- inhalt aus ./story.md -- unter target als card -- LMB öffnet story.md -- ist aufklappbar, standard ist eingeklappt - -areas -- alle areas zum projekt als cards -- enthält die features als flex liste -- featurename ist dateiname -- RMB auf area öffnet optionen zur area -- LMB auf feature öffnet die {feature}.md -- RMB auf feature öffnet die feature optionen - -area option create -- erstellt eine neuen area ordner -- modal für den area namen -- erstellen und abbrechen buttons - -area option rename -- ändert den ordnernamen zur area -- modal mit dem aktuellen area namen -- speichern und abbrechen buttons - -area option delete -- entfernt area rekursiv -- muss bestätigt werden - -feature option create -- erstellt eine neue feature md datei im area ordner -- modal für den feature namen -- erstellen und abbrechen buttons - -feature option delete -- entfernt feature diff --git a/doc/overview/__.md b/doc/overview/__.md new file mode 100644 index 0000000..b7af0ce --- /dev/null +++ b/doc/overview/__.md @@ -0,0 +1,5 @@ +- LMB auf projekt öffnet die projektdetails +- enthält details zum projekt +- layout als grid responsiv + - core+target + - areas \ No newline at end of file diff --git a/doc/overview/areas.md b/doc/overview/areas.md new file mode 100644 index 0000000..4b76add --- /dev/null +++ b/doc/overview/areas.md @@ -0,0 +1,20 @@ +- zwei varienten + - area mit features + - einzelnes feature +- beide sind button-cards +- area enthält features als flex liste +- featurename ist dateiname +- zuerst einzelne features verwenden +- areas, wenn 2 oder mehr features zusammen gehören +- RMB auf area container öffnet optionen zur area container + - area erstellen + - feature erstellen +- RMB auf area öffnet optionen zur area + - feature erstellen + - area umbenenen + - area löschen +- RMB auf feature öffnet die feature optionen + - feature löschen +- LMB auf feature öffnet die {feature}.md + + diff --git a/doc/overview/core.md b/doc/overview/core.md new file mode 100644 index 0000000..0beaf27 --- /dev/null +++ b/doc/overview/core.md @@ -0,0 +1,2 @@ +- inhalt aus `_core.md` +- LMB öffnet `_core.md` \ No newline at end of file diff --git a/doc/overview/createArea.md b/doc/overview/createArea.md new file mode 100644 index 0000000..dc4c3f6 --- /dev/null +++ b/doc/overview/createArea.md @@ -0,0 +1,3 @@ +- erstellt eine neuen area ordner +- modal für den area namen +- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/overview/createFeature.md b/doc/overview/createFeature.md new file mode 100644 index 0000000..78f075a --- /dev/null +++ b/doc/overview/createFeature.md @@ -0,0 +1,3 @@ +- erstellt eine neue feature md datei im area ordner +- modal für den feature namen +- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/overview/deleteArea.md b/doc/overview/deleteArea.md new file mode 100644 index 0000000..5277565 --- /dev/null +++ b/doc/overview/deleteArea.md @@ -0,0 +1 @@ +- entfernt area rekursiv diff --git a/doc/overview/deleteFeature.md b/doc/overview/deleteFeature.md new file mode 100644 index 0000000..ad38ff7 --- /dev/null +++ b/doc/overview/deleteFeature.md @@ -0,0 +1 @@ +- entfernt feature \ No newline at end of file diff --git a/doc/overview/dragAndDrop.md b/doc/overview/dragAndDrop.md new file mode 100644 index 0000000..4dcd973 --- /dev/null +++ b/doc/overview/dragAndDrop.md @@ -0,0 +1,4 @@ +- featues kann man per d&d verschieben + - featue aus area -> area container + - featue aus area container -> area + - featue aus area -> area diff --git a/doc/overview/renameArea.md b/doc/overview/renameArea.md new file mode 100644 index 0000000..03aef57 --- /dev/null +++ b/doc/overview/renameArea.md @@ -0,0 +1,3 @@ +- ändert den ordnernamen zur area +- modal mit dem aktuellen area namen +- speichern und abbrechen buttons \ No newline at end of file diff --git a/doc/overview/target.md b/doc/overview/target.md new file mode 100644 index 0000000..2f78a3e --- /dev/null +++ b/doc/overview/target.md @@ -0,0 +1,2 @@ +- inhalt aus `_target.md` +- LMB öffnet `_target.md` \ No newline at end of file diff --git a/doc/project.md b/doc/project.md index db40996..adad3e9 100644 --- a/doc/project.md +++ b/doc/project.md @@ -1,9 +1,3 @@ -- kann ich projekt oder subprojekt zu einem großen projekt sein +- root ordner zum projekt - ist abgeschlossen, also hat keine abhängigkeit -- ist der root ordner - ordnername ist der projektname - -kerndatei -- wird als project.md gespeichert -- enthält kern, zweck, stories und kategorien als md dateien -- enthält bereiche als ordner diff --git a/doc/projects.md b/doc/projects.md deleted file mode 100644 index df4b305..0000000 --- a/doc/projects.md +++ /dev/null @@ -1,28 +0,0 @@ -- LMB auf plugin icon öffnet die projektübersicht -- enthält alle projekte - -plugin icon -- 9 app icon -- icon links in obsidian navigation - -projekte -- lädt alle ordner aus ./projects/ -- cards als buttons -- grid layout -- name ist ordnername -- RMB auf Card öffnet optionen zum projekt - -option create -- erstellt ein neues projekt -- erstellt automatisch core.md, target.md und story.md -- modal für den projektnamen -- erstellen und abbrechen buttons - -option rename -- ändert den ordnernamen zum projekt -- modal mit dem aktuellen projektnamen -- speichern und abbrechen buttons - -option delete -- entfernt projekt rekursiv -- muss bestätigt werden \ No newline at end of file diff --git a/doc/projects/__.md b/doc/projects/__.md new file mode 100644 index 0000000..69686b0 --- /dev/null +++ b/doc/projects/__.md @@ -0,0 +1,2 @@ +- enthält alle projekte +- LMB auf plugin icon öffnet die projektübersicht diff --git a/doc/projects/createProject.md b/doc/projects/createProject.md new file mode 100644 index 0000000..7a9693e --- /dev/null +++ b/doc/projects/createProject.md @@ -0,0 +1,4 @@ +- erstellt ein neues projekt +- erstellt automatisch `_core.md`, `_target.md` und story area +- modal für den projektnamen +- erstellen und abbrechen buttons \ No newline at end of file diff --git a/doc/projects/deleteProject.md b/doc/projects/deleteProject.md new file mode 100644 index 0000000..00e822b --- /dev/null +++ b/doc/projects/deleteProject.md @@ -0,0 +1 @@ +- entfernt projekt rekursiv diff --git a/doc/projects/listing.md b/doc/projects/listing.md new file mode 100644 index 0000000..d83963f --- /dev/null +++ b/doc/projects/listing.md @@ -0,0 +1,9 @@ +- lädt alle ordner aus ./projects/ +- projekte als button-cards +- grid layout +- name ist ordnername bold und core inhalt dadrunter +- RMB auf projekt Container öffnet optionen zum projekt + - projekt erstellen +- RMB auf projekt öffnet optionen zum projekt + - projekt umbenennen + - projekt löschen \ No newline at end of file diff --git a/doc/projects/pluginIcon.md b/doc/projects/pluginIcon.md new file mode 100644 index 0000000..2d0579c --- /dev/null +++ b/doc/projects/pluginIcon.md @@ -0,0 +1,2 @@ +- layout-grid icon +- icon links in obsidian navigation \ No newline at end of file diff --git a/doc/projects/renameProject.md b/doc/projects/renameProject.md new file mode 100644 index 0000000..d507488 --- /dev/null +++ b/doc/projects/renameProject.md @@ -0,0 +1,3 @@ +- ändert den ordnernamen zum projekt +- modal mit dem aktuellen projektnamen +- speichern und abbrechen buttons \ No newline at end of file diff --git a/doc/story.md b/doc/story.md deleted file mode 100644 index b6f651e..0000000 --- a/doc/story.md +++ /dev/null @@ -1,5 +0,0 @@ -- beschreibt wie das fertige projekt in der regel verwendet werden soll -- Max 1024 Zeichen -- wird als `./_story.md` gespeichert -- nur die hauptanwendung -- abfolge von schritten (start ... ende) \ No newline at end of file diff --git a/doc/story/__.md b/doc/story/__.md new file mode 100644 index 0000000..f1ce7a2 --- /dev/null +++ b/doc/story/__.md @@ -0,0 +1,4 @@ +- beschreibt wie das fertige projekt in der regel verwendet werden soll +- abfolge von schritten (start ... ende) +- eine oder mehrere stories +- stehen in der area story \ No newline at end of file diff --git a/doc/_story.md b/doc/story/erstellen.md similarity index 100% rename from doc/_story.md rename to doc/story/erstellen.md diff --git a/doc/story/erweitern.md b/doc/story/erweitern.md new file mode 100644 index 0000000..cd7801c --- /dev/null +++ b/doc/story/erweitern.md @@ -0,0 +1,4 @@ +- Projekt soll erweitert werden +- Areas und Features erstellen / anpassen / entfernen +- Features implementieren +- Projekt v2 \ No newline at end of file diff --git a/doc/target.md b/doc/target.md index 644e594..ff05b55 100644 --- a/doc/target.md +++ b/doc/target.md @@ -1,6 +1,10 @@ - beschreibt simpel welches problem das projekt löst - - Was ist das Problem und die Folge davon? - - Wie löst das Projekt das Problem und was ist die Folge davon? +- Folgende Fragen werden beantwortet + - Was ist das Problem? + - Was ist die Folge vom Problem? + - Wie löst das Projekt das Problem? + - Was ist die folge der Lösung? - der zweck muss klar erkennbar sein -- max 255 zeichen +- max 256 zeichen - wird als `./_target.md` gespeichert +- als md gerendert diff --git a/main.ts b/main.ts index 7518c98..7e922b9 100644 --- a/main.ts +++ b/main.ts @@ -8,10 +8,8 @@ import { import { ProjectsView } from "./src/views/ProjectsView"; import { OverviewView } from "./src/views/OverviewView"; import { DetailsView } from "./src/views/DetailsView"; -import { consumeMobileReturn } from "./src/ui"; - -const SWIPE_THRESHOLD = 80; -const SWIPE_MAX_VERTICAL = 50; +import { parseProjectFilePath } from "./src/fs"; +import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui"; export default class ProjektkontextPlugin extends Plugin { async onload(): Promise { @@ -29,7 +27,16 @@ export default class ProjektkontextPlugin extends Plugin { callback: () => void this.activateProjectsView(), }); - if (Platform.isMobile) this.installMobileSwipe(); + if (Platform.isMobile) { + const reattach = () => this.reattachMobileBreadcrumbs(); + this.registerEvent( + this.app.workspace.on("file-open", () => requestAnimationFrame(reattach)), + ); + this.registerEvent( + this.app.workspace.on("active-leaf-change", () => requestAnimationFrame(reattach)), + ); + this.registerEvent(this.app.workspace.on("layout-change", reattach)); + } } async onunload(): Promise {} @@ -44,40 +51,50 @@ export default class ProjektkontextPlugin extends Plugin { workspace.revealLeaf(leaf); } - private installMobileSwipe(): void { - let startX = 0; - let startY = 0; - let active = false; - - this.registerDomEvent(document, "touchstart", (ev: TouchEvent) => { - if (ev.touches.length !== 1) return; - const view = this.app.workspace.getActiveViewOfType(MarkdownView); - if (!view) return; - startX = ev.touches[0].clientX; - startY = ev.touches[0].clientY; - active = true; - }); - - this.registerDomEvent(document, "touchend", (ev: TouchEvent) => { - if (!active) return; - active = false; - const t = ev.changedTouches[0]; - const dx = t.clientX - startX; - const dy = Math.abs(t.clientY - startY); - if (dx < SWIPE_THRESHOLD || dy > SWIPE_MAX_VERTICAL) return; - const target = consumeMobileReturn(); - if (!target) return; - void this.restoreView(target.type, target.state); - }); + private reattachMobileBreadcrumbs(): void { + this.app.workspace.iterateRootLeaves((leaf) => this.applyMobileBreadcrumb(leaf)); } - private async restoreView(type: string, state: unknown): Promise { - const leaf = this.app.workspace.getLeaf(false); - await leaf.setViewState({ - type, - active: true, - state: (state as Record) ?? {}, - }); - this.app.workspace.revealLeaf(leaf); + private applyMobileBreadcrumb(leaf: WorkspaceLeaf): void { + const view = leaf.view; + if (!(view instanceof MarkdownView)) return; + const file = view.file; + if (!file) { + injectMobileBreadcrumb(view, []); + return; + } + const loc = parseProjectFilePath(file.path); + if (!loc) { + injectMobileBreadcrumb(view, []); + return; + } + const segments: BreadcrumbSegment[] = [ + { + label: "Projekte", + onClick: () => void leaf.setViewState({ type: VIEW_TYPE_PROJECTS, active: true }), + }, + { + label: loc.project, + onClick: () => + void leaf.setViewState({ + type: VIEW_TYPE_OVERVIEW, + active: true, + state: { project: loc.project }, + }), + }, + ]; + if (loc.area) { + segments.push({ + label: loc.area, + onClick: () => + void leaf.setViewState({ + type: VIEW_TYPE_DETAILS, + active: true, + state: { project: loc.project, area: loc.area }, + }), + }); + } + segments.push({ label: file.basename }); + injectMobileBreadcrumb(view, segments); } } diff --git a/src/const.ts b/src/const.ts index 26827a7..594ab7a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -6,9 +6,7 @@ export const VIEW_TYPE_DETAILS = "projektkontext-details"; export const RIBBON_ICON = "layout-grid"; -export const PROJECT_FILE = "_project.md"; export const CORE_FILE = "_core.md"; export const TARGET_FILE = "_target.md"; -export const STORY_FILE = "_story.md"; -export const PROJECT_FILES = [PROJECT_FILE, CORE_FILE, TARGET_FILE, STORY_FILE] as const; +export const PROJECT_FILES = [CORE_FILE, TARGET_FILE] as const; diff --git a/src/fs.ts b/src/fs.ts index c49641a..8f218c9 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -79,6 +79,7 @@ export async function createProject(app: App, name: string): Promise { for (const file of PROJECT_FILES) { await ensureFile(app, normalizePath(`${root}/${file}`), ""); } + await createArea(app, name, "story"); } export async function createArea(app: App, project: string, area: string): Promise { @@ -110,3 +111,17 @@ export async function createProjectFeature( export function isProjectRootFile(name: string): boolean { return (PROJECT_FILES as readonly string[]).includes(name); } + +export interface ProjectFileLocation { + project: string; + area?: string; +} + +export function parseProjectFilePath(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 { project: parts[1] }; + if (parts.length === 4) return { project: parts[1], area: parts[2] }; + return null; +} diff --git a/src/modals/ConfirmModal.ts b/src/modals/ConfirmModal.ts deleted file mode 100644 index b385705..0000000 --- a/src/modals/ConfirmModal.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { App, Modal, Setting } from "obsidian"; - -export interface ConfirmModalOptions { - title: string; - message: string; - cta: string; - destructive?: boolean; - onConfirm: () => void | Promise; -} - -export class ConfirmModal extends Modal { - private opts: ConfirmModalOptions; - - constructor(app: App, opts: ConfirmModalOptions) { - super(app); - this.opts = opts; - } - - onOpen(): void { - this.titleEl.setText(this.opts.title); - this.contentEl.createDiv({ cls: "pk-confirm-msg", text: this.opts.message }); - new Setting(this.contentEl) - .addButton((b) => b.setButtonText("Abbrechen").onClick(() => this.close())) - .addButton((b) => { - b.setButtonText(this.opts.cta).setCta(); - if (this.opts.destructive) b.setWarning(); - b.onClick(async () => { - await this.opts.onConfirm(); - this.close(); - }); - }); - } - - onClose(): void { - this.contentEl.empty(); - } -} diff --git a/src/ui.ts b/src/ui.ts index 614b22e..7074491 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,4 +1,4 @@ -import { App, Menu, Platform, TFile, normalizePath } from "obsidian"; +import { App, Menu, Platform, TFile, View, WorkspaceLeaf, normalizePath } from "obsidian"; export interface CardOptions { title: string; @@ -44,31 +44,17 @@ export function menu(ev: MouseEvent, items: Array<{ title: string; icon?: string m.showAtMouseEvent(ev); } -export interface MobileReturnTarget { - type: string; - state?: unknown; -} - -let pendingReturn: MobileReturnTarget | null = null; - -export function consumeMobileReturn(): MobileReturnTarget | null { - const r = pendingReturn; - pendingReturn = null; - return r; -} - export async function openMarkdown( app: App, path: string, - mobileReturn?: MobileReturnTarget, + leaf?: WorkspaceLeaf, ): Promise { const p = normalizePath(path); const f = app.vault.getAbstractFileByPath(p); if (!(f instanceof TFile)) return; if (Platform.isMobile) { - if (mobileReturn) pendingReturn = mobileReturn; - const leaf = app.workspace.getLeaf(false); - await leaf.openFile(f); + const target = leaf ?? app.workspace.getLeaf(false); + await target.openFile(f); return; } const existing = app.workspace.getLeavesOfType("markdown"); @@ -77,8 +63,8 @@ export async function openMarkdown( await target.openFile(f); app.workspace.revealLeaf(target); } else { - const leaf = app.workspace.getLeaf("split", "vertical"); - await leaf.openFile(f); + const target = app.workspace.getLeaf("split", "vertical"); + await target.openFile(f); } } @@ -87,8 +73,7 @@ export interface BreadcrumbSegment { onClick?: () => void; } -export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement { - const wrap = parent.createDiv({ cls: "pk-breadcrumb" }); +function renderBreadcrumbInto(wrap: HTMLElement, segments: BreadcrumbSegment[]): void { segments.forEach((seg, i) => { if (i > 0) wrap.createSpan({ cls: "pk-breadcrumb-sep", text: " > " }); if (seg.onClick) { @@ -98,9 +83,32 @@ export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): wrap.createSpan({ cls: "pk-breadcrumb-current", text: seg.label }); } }); +} + +export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement { + const wrap = parent.createDiv({ cls: "pk-breadcrumb" }); + renderBreadcrumbInto(wrap, segments); return wrap; } +export function injectMobileBreadcrumb( + view: View & { contentEl?: HTMLElement }, + segments: BreadcrumbSegment[], +): void { + const root = view.contentEl ?? view.containerEl.querySelector(".view-content"); + if (!root) return; + root.querySelectorAll(".pk-mobile-breadcrumb").forEach((el) => el.remove()); + if (segments.length === 0) return; + const target = + root.querySelector(".markdown-source-view .cm-sizer") ?? + root.querySelector(".markdown-reading-view .markdown-preview-sizer"); + if (!target) return; + const wrap = document.createElement("div"); + wrap.className = "pk-breadcrumb pk-mobile-breadcrumb"; + target.prepend(wrap); + renderBreadcrumbInto(wrap, segments); +} + export function attachEmptyAreaMenu( el: HTMLElement, items: () => Array<{ title: string; icon?: string; onClick: () => void }>, diff --git a/src/views/DetailsView.ts b/src/views/DetailsView.ts index cce343b..e81ee15 100644 --- a/src/views/DetailsView.ts +++ b/src/views/DetailsView.ts @@ -1,4 +1,4 @@ -import { ItemView, WorkspaceLeaf, ViewStateResult } from "obsidian"; +import { ItemView, MarkdownRenderer, WorkspaceLeaf, ViewStateResult } from "obsidian"; import { VIEW_TYPE_DETAILS, VIEW_TYPE_OVERVIEW, @@ -9,12 +9,12 @@ import { areaPath, featurePath, listMarkdownFiles, + readFile, createFeature, deleteRecursive, } from "../fs"; -import { card, menu, breadcrumb, emptyState, openMarkdown } from "../ui"; +import { menu, breadcrumb, emptyState, openMarkdown } from "../ui"; import { NameModal } from "../modals/NameModal"; -import { ConfirmModal } from "../modals/ConfirmModal"; export interface DetailsState extends Record { project: string; @@ -33,9 +33,11 @@ function validateName(name: string, taken: string[]): string | null { export class DetailsView extends ItemView { project = ""; area = ""; + private renderToken = 0; constructor(leaf: WorkspaceLeaf) { super(leaf); + this.navigation = true; } getViewType(): string { @@ -78,40 +80,54 @@ export class DetailsView extends ItemView { async onClose(): Promise {} private async render(): Promise { + const token = ++this.renderToken; const root = this.containerEl.children[1] as HTMLElement; - root.empty(); - root.addClass("pk-root"); if (!this.project || !this.area) { + root.empty(); + root.addClass("pk-root"); emptyState(root, "Keine Area ausgewählt."); return; } + const features = listMarkdownFiles(this.app, areaPath(this.project, this.area), []); + const contents = await Promise.all(features.map((f) => readFile(this.app, f.path))); + if (token !== this.renderToken) return; + + root.empty(); + root.addClass("pk-root"); + breadcrumb(root, [ { label: "Projekte", onClick: () => this.openProjectsView() }, { label: this.project, onClick: () => this.openOverview() }, { label: this.area }, ]); - const features = listMarkdownFiles(this.app, areaPath(this.project, this.area), []); if (features.length === 0) { emptyState(root, "Noch keine Features. Rechtsklick → Neues Feature."); return; } - const grid = root.createDiv({ cls: "pk-grid" }); - for (const f of features) { - card(grid, { - title: f.basename, - onClick: () => - openMarkdown(this.app, f.path, { - type: VIEW_TYPE_DETAILS, - state: { project: this.project, area: this.area }, - }), - onContextMenu: (ev) => - menu(ev, [ - { title: "Feature löschen", icon: "trash", onClick: () => this.openDelete(f.basename) }, - ]), + const section = root.createDiv({ cls: "pk-features-section" }); + const grid = section.createDiv({ cls: "pk-features-flex" }); + for (let i = 0; i < features.length; i++) { + const f = features[i]; + const content = contents[i]; + const btn = grid.createDiv({ + cls: "pk-btn-card", + attr: { role: "button", tabindex: "0" }, + }); + btn.createEl("strong", { text: f.basename }); + const body = btn.createDiv({ cls: "pk-feature-body" }); + if (content.trim()) { + void MarkdownRenderer.render(this.app, content, body, f.path, this); + } + btn.addEventListener("click", () => openMarkdown(this.app, f.path, this.leaf)); + btn.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + menu(ev, [ + { title: "Feature löschen", icon: "trash", onClick: () => this.openDelete(f.basename) }, + ]); }); } } @@ -132,17 +148,9 @@ export class DetailsView extends ItemView { }).open(); } - private openDelete(feature: string): void { - new ConfirmModal(this.app, { - title: "Feature löschen", - message: `Feature „${feature}" wird gelöscht. Fortfahren?`, - cta: "Löschen", - destructive: true, - onConfirm: async () => { - await deleteRecursive(this.app, featurePath(this.project, this.area, feature)); - await this.render(); - }, - }).open(); + private async openDelete(feature: string): Promise { + await deleteRecursive(this.app, featurePath(this.project, this.area, feature)); + await this.render(); } private async openOverview(): Promise { diff --git a/src/views/OverviewView.ts b/src/views/OverviewView.ts index 0248125..4fb6123 100644 --- a/src/views/OverviewView.ts +++ b/src/views/OverviewView.ts @@ -1,4 +1,4 @@ -import { ItemView, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian"; +import { ItemView, MarkdownRenderer, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian"; import { VIEW_TYPE_OVERVIEW, VIEW_TYPE_DETAILS, @@ -7,7 +7,6 @@ import { PROJECT_FILES, CORE_FILE, TARGET_FILE, - STORY_FILE, } from "../const"; import { projectPath, @@ -25,7 +24,6 @@ import { } from "../fs"; import { menu, breadcrumb, emptyState, openMarkdown } from "../ui"; import { NameModal } from "../modals/NameModal"; -import { ConfirmModal } from "../modals/ConfirmModal"; export interface OverviewState extends Record { project: string; @@ -43,11 +41,11 @@ function validateName(name: string, taken: string[], current?: string): string | export class OverviewView extends ItemView { project = ""; - private storyOpen = false; private renderToken = 0; constructor(leaf: WorkspaceLeaf) { super(leaf); + this.navigation = true; } getViewType(): string { @@ -98,11 +96,9 @@ export class OverviewView extends ItemView { const projRoot = projectPath(this.project); const corePath = normalizePath(`${projRoot}/${CORE_FILE}`); const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`); - const storyPath = normalizePath(`${projRoot}/${STORY_FILE}`); - const [core, target, story] = await Promise.all([ + const [core, target] = await Promise.all([ readFile(this.app, corePath), readFile(this.app, targetPath), - readFile(this.app, storyPath), ]); if (token !== this.renderToken) return; @@ -114,50 +110,27 @@ export class OverviewView extends ItemView { { label: this.project }, ]); - const stack = root.createDiv({ cls: "pk-stack" }); - this.renderInfoCard(stack, core, corePath); - this.renderInfoCard(stack, target, targetPath); - this.renderStoryCard(stack, story, storyPath); + const info = root.createDiv({ cls: "pk-info-grid" }); + this.renderInfoCard(info, core, corePath); + this.renderInfoCard(info, target, targetPath); this.renderAreas(root); } private renderInfoCard(parent: HTMLElement, content: string, path: string): void { const btn = parent.createDiv({ - cls: "pk-btn-card", + cls: "pk-btn-card pk-info-card", attr: { role: "button", tabindex: "0" }, }); - btn.setText(content || "(leer)"); - btn.addEventListener("click", () => - openMarkdown(this.app, path, { - type: VIEW_TYPE_OVERVIEW, - state: { project: this.project }, - }), - ); - } - - private renderStoryCard(parent: HTMLElement, content: string, path: string): void { - const el = parent.createDiv({ cls: "pk-card" }); - const header = el.createDiv({ cls: "pk-card-header pk-clickable pk-collapsible-toggle" }); - header.setText(`${this.storyOpen ? "▾" : "▸"} Story`); - header.addEventListener("click", () => { - this.storyOpen = !this.storyOpen; - void this.render(); - }); - if (this.storyOpen) { - const body = el.createDiv({ cls: "pk-card-body pk-clickable" }); - body.setText(content || "(leer)"); - body.addEventListener("click", () => - openMarkdown(this.app, path, { - type: VIEW_TYPE_OVERVIEW, - state: { project: this.project }, - }), - ); + 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)"); } } private renderAreas(parent: HTMLElement): void { const section = parent.createDiv({ cls: "pk-areas-section" }); - section.createDiv({ cls: "pk-section-title", text: "Areas" }); section.addEventListener("contextmenu", (ev) => { if (ev.defaultPrevented) return; ev.preventDefault(); @@ -258,10 +231,7 @@ export class OverviewView extends ItemView { }); chip.addEventListener("click", (ev) => { ev.stopPropagation(); - openMarkdown(this.app, f.path, { - type: VIEW_TYPE_OVERVIEW, - state: { project: this.project }, - }); + openMarkdown(this.app, f.path, this.leaf); }); chip.addEventListener("contextmenu", (ev) => { ev.preventDefault(); @@ -292,12 +262,7 @@ export class OverviewView extends ItemView { fc.addEventListener("dragend", () => { fc.removeClass("pk-feature-chip-dragging"); }); - fc.addEventListener("click", () => - openMarkdown(this.app, f.path, { - type: VIEW_TYPE_OVERVIEW, - state: { project: this.project }, - }), - ); + fc.addEventListener("click", () => openMarkdown(this.app, f.path, this.leaf)); fc.addEventListener("contextmenu", (ev) => { ev.preventDefault(); ev.stopPropagation(); @@ -322,17 +287,9 @@ export class OverviewView extends ItemView { }).open(); } - private openDeleteProjectFeature(feature: string): void { - new ConfirmModal(this.app, { - title: "Feature löschen", - message: `Feature „${feature}" wird gelöscht. Fortfahren?`, - cta: "Löschen", - destructive: true, - onConfirm: async () => { - await deleteRecursive(this.app, projectFeaturePath(this.project, feature)); - await this.render(); - }, - }).open(); + private async openDeleteProjectFeature(feature: string): Promise { + await deleteRecursive(this.app, projectFeaturePath(this.project, feature)); + await this.render(); } private async handleFeatureDropToProject(raw: string): Promise { @@ -418,17 +375,9 @@ export class OverviewView extends ItemView { }).open(); } - private openDeleteArea(name: string): void { - new ConfirmModal(this.app, { - title: "Area löschen", - message: `Area „${name}" wird mit allen Features rekursiv gelöscht. Fortfahren?`, - cta: "Löschen", - destructive: true, - onConfirm: async () => { - await deleteRecursive(this.app, areaPath(this.project, name)); - await this.render(); - }, - }).open(); + private async openDeleteArea(name: string): Promise { + await deleteRecursive(this.app, areaPath(this.project, name)); + await this.render(); } private openCreateFeature(area: string): void { @@ -445,17 +394,9 @@ export class OverviewView extends ItemView { }).open(); } - private openDeleteFeature(area: string, feature: string): void { - new ConfirmModal(this.app, { - title: "Feature löschen", - message: `Feature „${feature}" wird gelöscht. Fortfahren?`, - cta: "Löschen", - destructive: true, - onConfirm: async () => { - await deleteRecursive(this.app, featurePath(this.project, area, feature)); - await this.render(); - }, - }).open(); + private async openDeleteFeature(area: string, feature: string): Promise { + await deleteRecursive(this.app, featurePath(this.project, area, feature)); + await this.render(); } private async openDetails(area: string): Promise { diff --git a/src/views/ProjectsView.ts b/src/views/ProjectsView.ts index 2a5688a..0479bc6 100644 --- a/src/views/ProjectsView.ts +++ b/src/views/ProjectsView.ts @@ -12,7 +12,6 @@ import { } from "../fs"; import { menu, breadcrumb, emptyState } from "../ui"; import { NameModal } from "../modals/NameModal"; -import { ConfirmModal } from "../modals/ConfirmModal"; const NAME_RX = /^[^\\/:*?"<>|]+$/; @@ -24,8 +23,11 @@ function validateName(name: string, taken: string[], current?: string): string | } export class ProjectsView extends ItemView { + private renderToken = 0; + constructor(leaf: WorkspaceLeaf) { super(leaf); + this.navigation = true; } getViewType(): string { @@ -63,14 +65,15 @@ export class ProjectsView extends ItemView { async onClose(): Promise {} private async render(): Promise { + const token = ++this.renderToken; const root = this.containerEl.children[1] as HTMLElement; - root.empty(); - root.addClass("pk-root"); - - breadcrumb(root, [{ label: "Projekte" }]); const projects = listFolders(this.app, projectsPath()); if (projects.length === 0) { + if (token !== this.renderToken) return; + root.empty(); + root.addClass("pk-root"); + breadcrumb(root, [{ label: "Projekte" }]); emptyState(root, "Noch keine Projekte. Rechtsklick → Neues Projekt."); return; } @@ -81,6 +84,11 @@ export class ProjectsView extends ItemView { readFile(this.app, normalizePath(`${projectPath(p.name)}/${CORE_FILE}`)), ), ); + if (token !== this.renderToken) return; + + root.empty(); + root.addClass("pk-root"); + breadcrumb(root, [{ label: "Projekte" }]); const grid = root.createDiv({ cls: "pk-grid" }); for (let i = 0; i < projects.length; i++) { @@ -141,21 +149,13 @@ export class ProjectsView extends ItemView { }).open(); } - private openDelete(name: string): void { - new ConfirmModal(this.app, { - title: "Projekt löschen", - message: `Projekt „${name}" wird mitsamt aller Areas und Features rekursiv gelöscht. Fortfahren?`, - cta: "Löschen", - destructive: true, - onConfirm: async () => { - try { - await deleteRecursive(this.app, projectPath(name)); - await this.render(); - } catch (e) { - new Notice(`Fehler: ${(e as Error).message}`); - } - }, - }).open(); + private async openDelete(name: string): Promise { + try { + await deleteRecursive(this.app, projectPath(name)); + await this.render(); + } catch (e) { + new Notice(`Fehler: ${(e as Error).message}`); + } } private async openOverview(name: string): Promise { diff --git a/styles.css b/styles.css index a5ad3e4..44f3b53 100644 --- a/styles.css +++ b/styles.css @@ -3,6 +3,7 @@ display: flex; flex-direction: column; gap: 12px; + container-type: inline-size; } .pk-toolbar { @@ -23,10 +24,32 @@ gap: 10px; } -.pk-stack { +.pk-features-section { display: flex; flex-direction: column; - gap: 10px; + gap: 5px; +} + +.pk-features-flex { + column-width: 300px; + column-gap: 5px; +} + +.pk-features-flex > .pk-btn-card { + break-inside: avoid; + margin-bottom: 5px; +} + +.pk-info-grid { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 5px; +} + +@container (max-width: 500px) { + .pk-info-grid { + grid-template-columns: 1fr; + } } .pk-card { @@ -136,6 +159,18 @@ font-weight: 400; } +.pk-mobile-breadcrumb { + position: relative; + z-index: 5; + background: var(--background-primary); + width: 100%; + flex-shrink: 0; + padding: 8px 12px; + margin-bottom: 8px; + border-bottom: 1px solid var(--background-modifier-border); + box-sizing: border-box; +} + .pk-btn-card { box-sizing: border-box; padding: 10px; @@ -157,6 +192,20 @@ font-weight: 600; } +.pk-info-card > p:first-child { margin-top: 0; } +.pk-info-card > p:last-child { margin-bottom: 0; } +.pk-info-card > ul:first-child, +.pk-info-card > ol:first-child { margin-top: 0; } +.pk-info-card > ul:last-child, +.pk-info-card > ol:last-child { margin-bottom: 0; } + +.pk-feature-body > p:first-child { margin-top: 0; } +.pk-feature-body > p:last-child { margin-bottom: 0; } +.pk-feature-body > ul:first-child, +.pk-feature-body > ol:first-child { margin-top: 0; } +.pk-feature-body > ul:last-child, +.pk-feature-body > ol:last-child { margin-bottom: 0; } + .pk-drop-target { outline: 2px dashed var(--text-accent); @@ -175,16 +224,15 @@ .pk-areas-section { display: flex; flex-direction: column; - gap: 10px; - padding: 5px; + gap: 5px; } .pk-areas-flex { - column-width: 250px; - column-gap: 10px; + column-width: 200px; + column-gap: 5px; } .pk-area-card { break-inside: avoid; - margin-bottom: 10px; + margin-bottom: 5px; }