This commit is contained in:
2026-05-01 18:37:24 +02:00
parent 6ea56fb5e0
commit dddbdeb616
46 changed files with 362 additions and 390 deletions

9
doc/__/desktop.md Normal file
View File

@@ -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

2
doc/__/mobile.md Normal file
View File

@@ -0,0 +1,2 @@
- projektübersicht, projektdetails, areadetails und md dateien bleiben im selben fenster
- navigation über die breadcrumb

View File

@@ -1 +1,2 @@
Obsidian Plugin - Projektkontext managen **Obsidian Plugin**
Projektkontext managen

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,4 @@
- container für features - sammlung von features
- Auflistung als Columns - Auflistung als Columns (aufstockend)
- wird gespeichert als `./{area}/` - wird gespeichert als `./{area}/`
- `__` sind allgemeine zum ordner

7
doc/breadcrumb.md Normal file
View File

@@ -0,0 +1,7 @@
- steht ganz oben
- desktop
- Projekte > {projektname}
- Projekte > {projektname} > {areaname}
- mobile
- Projekte > {projektname} > {md-name}
- Projekte > {projektname} > {areaname} > {md-name}

View File

@@ -1,3 +1,4 @@
- Max 64 Zeichen
- wird als `./_core.md` gespeichert
- beschreibt kompakt was das projekt ist - beschreibt kompakt was das projekt ist
- Max 128 Zeichen
- wird als `./_core.md` gespeichert
- als md gerendert

View File

@@ -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

3
doc/details/__.md Normal file
View File

@@ -0,0 +1,3 @@
- LMB auf area öffnet die areatdetails
- enthält details zur area
- layout: features

View File

@@ -0,0 +1,3 @@
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons

View File

@@ -0,0 +1,3 @@
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons

9
doc/details/features.md Normal file
View File

@@ -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

View File

@@ -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

5
doc/overview/__.md Normal file
View File

@@ -0,0 +1,5 @@
- LMB auf projekt öffnet die projektdetails
- enthält details zum projekt
- layout als grid responsiv
- core+target
- areas

20
doc/overview/areas.md Normal file
View File

@@ -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

2
doc/overview/core.md Normal file
View File

@@ -0,0 +1,2 @@
- inhalt aus `_core.md`
- LMB öffnet `_core.md`

View File

@@ -0,0 +1,3 @@
- erstellt eine neuen area ordner
- modal für den area namen
- erstellen und abbrechen buttons

View File

@@ -0,0 +1,3 @@
- erstellt eine neue feature md datei im area ordner
- modal für den feature namen
- erstellen und abbrechen buttons

View File

@@ -0,0 +1 @@
- entfernt area rekursiv

View File

@@ -0,0 +1 @@
- entfernt feature

View File

@@ -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

View File

@@ -0,0 +1,3 @@
- ändert den ordnernamen zur area
- modal mit dem aktuellen area namen
- speichern und abbrechen buttons

2
doc/overview/target.md Normal file
View File

@@ -0,0 +1,2 @@
- inhalt aus `_target.md`
- LMB öffnet `_target.md`

View File

@@ -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 abgeschlossen, also hat keine abhängigkeit
- ist der root ordner
- ordnername ist der projektname - 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

View File

@@ -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

2
doc/projects/__.md Normal file
View File

@@ -0,0 +1,2 @@
- enthält alle projekte
- LMB auf plugin icon öffnet die projektübersicht

View File

@@ -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

View File

@@ -0,0 +1 @@
- entfernt projekt rekursiv

9
doc/projects/listing.md Normal file
View File

@@ -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

View File

@@ -0,0 +1,2 @@
- layout-grid icon
- icon links in obsidian navigation

View File

@@ -0,0 +1,3 @@
- ändert den ordnernamen zum projekt
- modal mit dem aktuellen projektnamen
- speichern und abbrechen buttons

View File

@@ -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)

4
doc/story/__.md Normal file
View File

@@ -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

4
doc/story/erweitern.md Normal file
View File

@@ -0,0 +1,4 @@
- Projekt soll erweitert werden
- Areas und Features erstellen / anpassen / entfernen
- Features implementieren
- Projekt v2

View File

@@ -1,6 +1,10 @@
- beschreibt simpel welches problem das projekt löst - beschreibt simpel welches problem das projekt löst
- Was ist das Problem und die Folge davon? - Folgende Fragen werden beantwortet
- Wie löst das Projekt das Problem und was ist die Folge davon? - 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 - der zweck muss klar erkennbar sein
- max 255 zeichen - max 256 zeichen
- wird als `./_target.md` gespeichert - wird als `./_target.md` gespeichert
- als md gerendert

89
main.ts
View File

@@ -8,10 +8,8 @@ import {
import { ProjectsView } from "./src/views/ProjectsView"; import { ProjectsView } from "./src/views/ProjectsView";
import { OverviewView } from "./src/views/OverviewView"; import { OverviewView } from "./src/views/OverviewView";
import { DetailsView } from "./src/views/DetailsView"; import { DetailsView } from "./src/views/DetailsView";
import { consumeMobileReturn } from "./src/ui"; import { parseProjectFilePath } from "./src/fs";
import { BreadcrumbSegment, injectMobileBreadcrumb } from "./src/ui";
const SWIPE_THRESHOLD = 80;
const SWIPE_MAX_VERTICAL = 50;
export default class ProjektkontextPlugin extends Plugin { export default class ProjektkontextPlugin extends Plugin {
async onload(): Promise<void> { async onload(): Promise<void> {
@@ -29,7 +27,16 @@ export default class ProjektkontextPlugin extends Plugin {
callback: () => void this.activateProjectsView(), 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<void> {} async onunload(): Promise<void> {}
@@ -44,40 +51,50 @@ export default class ProjektkontextPlugin extends Plugin {
workspace.revealLeaf(leaf); workspace.revealLeaf(leaf);
} }
private installMobileSwipe(): void { private reattachMobileBreadcrumbs(): void {
let startX = 0; this.app.workspace.iterateRootLeaves((leaf) => this.applyMobileBreadcrumb(leaf));
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 async restoreView(type: string, state: unknown): Promise<void> { private applyMobileBreadcrumb(leaf: WorkspaceLeaf): void {
const leaf = this.app.workspace.getLeaf(false); const view = leaf.view;
await leaf.setViewState({ if (!(view instanceof MarkdownView)) return;
type, 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, active: true,
state: (state as Record<string, unknown>) ?? {}, 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 },
}),
}); });
this.app.workspace.revealLeaf(leaf); }
segments.push({ label: file.basename });
injectMobileBreadcrumb(view, segments);
} }
} }

View File

@@ -6,9 +6,7 @@ export const VIEW_TYPE_DETAILS = "projektkontext-details";
export const RIBBON_ICON = "layout-grid"; export const RIBBON_ICON = "layout-grid";
export const PROJECT_FILE = "_project.md";
export const CORE_FILE = "_core.md"; export const CORE_FILE = "_core.md";
export const TARGET_FILE = "_target.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;

View File

@@ -79,6 +79,7 @@ export async function createProject(app: App, name: string): Promise<void> {
for (const file of PROJECT_FILES) { for (const file of PROJECT_FILES) {
await ensureFile(app, normalizePath(`${root}/${file}`), ""); await ensureFile(app, normalizePath(`${root}/${file}`), "");
} }
await createArea(app, name, "story");
} }
export async function createArea(app: App, project: string, area: string): Promise<void> { export async function createArea(app: App, project: string, area: string): Promise<void> {
@@ -110,3 +111,17 @@ export async function createProjectFeature(
export function isProjectRootFile(name: string): boolean { export function isProjectRootFile(name: string): boolean {
return (PROJECT_FILES as readonly string[]).includes(name); 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;
}

View File

@@ -1,37 +0,0 @@
import { App, Modal, Setting } from "obsidian";
export interface ConfirmModalOptions {
title: string;
message: string;
cta: string;
destructive?: boolean;
onConfirm: () => void | Promise<void>;
}
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();
}
}

View File

@@ -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 { export interface CardOptions {
title: string; title: string;
@@ -44,31 +44,17 @@ export function menu(ev: MouseEvent, items: Array<{ title: string; icon?: string
m.showAtMouseEvent(ev); 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( export async function openMarkdown(
app: App, app: App,
path: string, path: string,
mobileReturn?: MobileReturnTarget, leaf?: WorkspaceLeaf,
): Promise<void> { ): Promise<void> {
const p = normalizePath(path); const p = normalizePath(path);
const f = app.vault.getAbstractFileByPath(p); const f = app.vault.getAbstractFileByPath(p);
if (!(f instanceof TFile)) return; if (!(f instanceof TFile)) return;
if (Platform.isMobile) { if (Platform.isMobile) {
if (mobileReturn) pendingReturn = mobileReturn; const target = leaf ?? app.workspace.getLeaf(false);
const leaf = app.workspace.getLeaf(false); await target.openFile(f);
await leaf.openFile(f);
return; return;
} }
const existing = app.workspace.getLeavesOfType("markdown"); const existing = app.workspace.getLeavesOfType("markdown");
@@ -77,8 +63,8 @@ export async function openMarkdown(
await target.openFile(f); await target.openFile(f);
app.workspace.revealLeaf(target); app.workspace.revealLeaf(target);
} else { } else {
const leaf = app.workspace.getLeaf("split", "vertical"); const target = app.workspace.getLeaf("split", "vertical");
await leaf.openFile(f); await target.openFile(f);
} }
} }
@@ -87,8 +73,7 @@ export interface BreadcrumbSegment {
onClick?: () => void; onClick?: () => void;
} }
export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]): HTMLElement { function renderBreadcrumbInto(wrap: HTMLElement, segments: BreadcrumbSegment[]): void {
const wrap = parent.createDiv({ cls: "pk-breadcrumb" });
segments.forEach((seg, i) => { segments.forEach((seg, i) => {
if (i > 0) wrap.createSpan({ cls: "pk-breadcrumb-sep", text: " > " }); if (i > 0) wrap.createSpan({ cls: "pk-breadcrumb-sep", text: " > " });
if (seg.onClick) { if (seg.onClick) {
@@ -98,9 +83,32 @@ export function breadcrumb(parent: HTMLElement, segments: BreadcrumbSegment[]):
wrap.createSpan({ cls: "pk-breadcrumb-current", text: seg.label }); 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; 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<HTMLElement>(".markdown-source-view .cm-sizer") ??
root.querySelector<HTMLElement>(".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( export function attachEmptyAreaMenu(
el: HTMLElement, el: HTMLElement,
items: () => Array<{ title: string; icon?: string; onClick: () => void }>, items: () => Array<{ title: string; icon?: string; onClick: () => void }>,

View File

@@ -1,4 +1,4 @@
import { ItemView, WorkspaceLeaf, ViewStateResult } from "obsidian"; import { ItemView, MarkdownRenderer, WorkspaceLeaf, ViewStateResult } from "obsidian";
import { import {
VIEW_TYPE_DETAILS, VIEW_TYPE_DETAILS,
VIEW_TYPE_OVERVIEW, VIEW_TYPE_OVERVIEW,
@@ -9,12 +9,12 @@ import {
areaPath, areaPath,
featurePath, featurePath,
listMarkdownFiles, listMarkdownFiles,
readFile,
createFeature, createFeature,
deleteRecursive, deleteRecursive,
} 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";
export interface DetailsState extends Record<string, unknown> { export interface DetailsState extends Record<string, unknown> {
project: string; project: string;
@@ -33,9 +33,11 @@ function validateName(name: string, taken: string[]): string | null {
export class DetailsView extends ItemView { export class DetailsView extends ItemView {
project = ""; project = "";
area = ""; area = "";
private renderToken = 0;
constructor(leaf: WorkspaceLeaf) { constructor(leaf: WorkspaceLeaf) {
super(leaf); super(leaf);
this.navigation = true;
} }
getViewType(): string { getViewType(): string {
@@ -78,40 +80,54 @@ export class DetailsView extends ItemView {
async onClose(): Promise<void> {} async onClose(): Promise<void> {}
private async render(): Promise<void> { private async render(): Promise<void> {
const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement; const root = this.containerEl.children[1] as HTMLElement;
root.empty();
root.addClass("pk-root");
if (!this.project || !this.area) { if (!this.project || !this.area) {
root.empty();
root.addClass("pk-root");
emptyState(root, "Keine Area ausgewählt."); emptyState(root, "Keine Area ausgewählt.");
return; 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, [ breadcrumb(root, [
{ label: "Projekte", onClick: () => this.openProjectsView() }, { label: "Projekte", onClick: () => this.openProjectsView() },
{ label: this.project, onClick: () => this.openOverview() }, { label: this.project, onClick: () => this.openOverview() },
{ label: this.area }, { label: this.area },
]); ]);
const features = listMarkdownFiles(this.app, areaPath(this.project, this.area), []);
if (features.length === 0) { if (features.length === 0) {
emptyState(root, "Noch keine Features. Rechtsklick → Neues Feature."); emptyState(root, "Noch keine Features. Rechtsklick → Neues Feature.");
return; return;
} }
const grid = root.createDiv({ cls: "pk-grid" }); const section = root.createDiv({ cls: "pk-features-section" });
for (const f of features) { const grid = section.createDiv({ cls: "pk-features-flex" });
card(grid, { for (let i = 0; i < features.length; i++) {
title: f.basename, const f = features[i];
onClick: () => const content = contents[i];
openMarkdown(this.app, f.path, { const btn = grid.createDiv({
type: VIEW_TYPE_DETAILS, cls: "pk-btn-card",
state: { project: this.project, area: this.area }, attr: { role: "button", tabindex: "0" },
}), });
onContextMenu: (ev) => 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, [ menu(ev, [
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDelete(f.basename) }, { title: "Feature löschen", icon: "trash", onClick: () => this.openDelete(f.basename) },
]), ]);
}); });
} }
} }
@@ -132,17 +148,9 @@ export class DetailsView extends ItemView {
}).open(); }).open();
} }
private openDelete(feature: string): void { private async openDelete(feature: string): Promise<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 deleteRecursive(this.app, featurePath(this.project, this.area, feature));
await this.render(); await this.render();
},
}).open();
} }
private async openOverview(): Promise<void> { private async openOverview(): Promise<void> {

View File

@@ -1,4 +1,4 @@
import { ItemView, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian"; import { ItemView, MarkdownRenderer, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
import { import {
VIEW_TYPE_OVERVIEW, VIEW_TYPE_OVERVIEW,
VIEW_TYPE_DETAILS, VIEW_TYPE_DETAILS,
@@ -7,7 +7,6 @@ import {
PROJECT_FILES, PROJECT_FILES,
CORE_FILE, CORE_FILE,
TARGET_FILE, TARGET_FILE,
STORY_FILE,
} from "../const"; } from "../const";
import { import {
projectPath, projectPath,
@@ -25,7 +24,6 @@ import {
} from "../fs"; } from "../fs";
import { 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";
export interface OverviewState extends Record<string, unknown> { export interface OverviewState extends Record<string, unknown> {
project: string; project: string;
@@ -43,11 +41,11 @@ function validateName(name: string, taken: string[], current?: string): string |
export class OverviewView extends ItemView { export class OverviewView extends ItemView {
project = ""; project = "";
private storyOpen = false;
private renderToken = 0; private renderToken = 0;
constructor(leaf: WorkspaceLeaf) { constructor(leaf: WorkspaceLeaf) {
super(leaf); super(leaf);
this.navigation = true;
} }
getViewType(): string { getViewType(): string {
@@ -98,11 +96,9 @@ export class OverviewView extends ItemView {
const projRoot = projectPath(this.project); const projRoot = projectPath(this.project);
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`); const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`); const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`);
const storyPath = normalizePath(`${projRoot}/${STORY_FILE}`); const [core, target] = await Promise.all([
const [core, target, story] = await Promise.all([
readFile(this.app, corePath), readFile(this.app, corePath),
readFile(this.app, targetPath), readFile(this.app, targetPath),
readFile(this.app, storyPath),
]); ]);
if (token !== this.renderToken) return; if (token !== this.renderToken) return;
@@ -114,50 +110,27 @@ export class OverviewView extends ItemView {
{ label: this.project }, { label: this.project },
]); ]);
const stack = root.createDiv({ cls: "pk-stack" }); const info = root.createDiv({ cls: "pk-info-grid" });
this.renderInfoCard(stack, core, corePath); this.renderInfoCard(info, core, corePath);
this.renderInfoCard(stack, target, targetPath); this.renderInfoCard(info, target, targetPath);
this.renderStoryCard(stack, story, storyPath);
this.renderAreas(root); this.renderAreas(root);
} }
private renderInfoCard(parent: HTMLElement, content: string, path: string): void { private renderInfoCard(parent: HTMLElement, content: string, path: string): void {
const btn = parent.createDiv({ const btn = parent.createDiv({
cls: "pk-btn-card", cls: "pk-btn-card pk-info-card",
attr: { role: "button", tabindex: "0" }, attr: { role: "button", tabindex: "0" },
}); });
btn.setText(content || "(leer)"); btn.addEventListener("click", () => openMarkdown(this.app, path, this.leaf));
btn.addEventListener("click", () => if (content.trim()) {
openMarkdown(this.app, path, { void MarkdownRenderer.render(this.app, content, btn, path, this);
type: VIEW_TYPE_OVERVIEW, } else {
state: { project: this.project }, btn.setText("(leer)");
}),
);
}
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 },
}),
);
} }
} }
private renderAreas(parent: HTMLElement): void { private renderAreas(parent: HTMLElement): void {
const section = parent.createDiv({ cls: "pk-areas-section" }); const section = parent.createDiv({ cls: "pk-areas-section" });
section.createDiv({ cls: "pk-section-title", text: "Areas" });
section.addEventListener("contextmenu", (ev) => { section.addEventListener("contextmenu", (ev) => {
if (ev.defaultPrevented) return; if (ev.defaultPrevented) return;
ev.preventDefault(); ev.preventDefault();
@@ -258,10 +231,7 @@ export class OverviewView extends ItemView {
}); });
chip.addEventListener("click", (ev) => { chip.addEventListener("click", (ev) => {
ev.stopPropagation(); ev.stopPropagation();
openMarkdown(this.app, f.path, { openMarkdown(this.app, f.path, this.leaf);
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
});
}); });
chip.addEventListener("contextmenu", (ev) => { chip.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
@@ -292,12 +262,7 @@ export class OverviewView extends ItemView {
fc.addEventListener("dragend", () => { fc.addEventListener("dragend", () => {
fc.removeClass("pk-feature-chip-dragging"); fc.removeClass("pk-feature-chip-dragging");
}); });
fc.addEventListener("click", () => fc.addEventListener("click", () => openMarkdown(this.app, f.path, this.leaf));
openMarkdown(this.app, f.path, {
type: VIEW_TYPE_OVERVIEW,
state: { project: this.project },
}),
);
fc.addEventListener("contextmenu", (ev) => { fc.addEventListener("contextmenu", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@@ -322,17 +287,9 @@ export class OverviewView extends ItemView {
}).open(); }).open();
} }
private openDeleteProjectFeature(feature: string): void { private async openDeleteProjectFeature(feature: string): Promise<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 deleteRecursive(this.app, projectFeaturePath(this.project, feature));
await this.render(); await this.render();
},
}).open();
} }
private async handleFeatureDropToProject(raw: string): Promise<void> { private async handleFeatureDropToProject(raw: string): Promise<void> {
@@ -418,17 +375,9 @@ export class OverviewView extends ItemView {
}).open(); }).open();
} }
private openDeleteArea(name: string): void { private async openDeleteArea(name: string): Promise<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 deleteRecursive(this.app, areaPath(this.project, name));
await this.render(); await this.render();
},
}).open();
} }
private openCreateFeature(area: string): void { private openCreateFeature(area: string): void {
@@ -445,17 +394,9 @@ export class OverviewView extends ItemView {
}).open(); }).open();
} }
private openDeleteFeature(area: string, feature: string): void { private async openDeleteFeature(area: string, feature: string): Promise<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 deleteRecursive(this.app, featurePath(this.project, area, feature));
await this.render(); await this.render();
},
}).open();
} }
private async openDetails(area: string): Promise<void> { private async openDetails(area: string): Promise<void> {

View File

@@ -12,7 +12,6 @@ import {
} from "../fs"; } from "../fs";
import { 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";
const NAME_RX = /^[^\\/:*?"<>|]+$/; const NAME_RX = /^[^\\/:*?"<>|]+$/;
@@ -24,8 +23,11 @@ function validateName(name: string, taken: string[], current?: string): string |
} }
export class ProjectsView extends ItemView { export class ProjectsView extends ItemView {
private renderToken = 0;
constructor(leaf: WorkspaceLeaf) { constructor(leaf: WorkspaceLeaf) {
super(leaf); super(leaf);
this.navigation = true;
} }
getViewType(): string { getViewType(): string {
@@ -63,14 +65,15 @@ export class ProjectsView extends ItemView {
async onClose(): Promise<void> {} async onClose(): Promise<void> {}
private async render(): Promise<void> { private async render(): Promise<void> {
const token = ++this.renderToken;
const root = this.containerEl.children[1] as HTMLElement; const root = this.containerEl.children[1] as HTMLElement;
root.empty();
root.addClass("pk-root");
breadcrumb(root, [{ label: "Projekte" }]);
const projects = listFolders(this.app, projectsPath()); const projects = listFolders(this.app, projectsPath());
if (projects.length === 0) { 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."); emptyState(root, "Noch keine Projekte. Rechtsklick → Neues Projekt.");
return; return;
} }
@@ -81,6 +84,11 @@ export class ProjectsView extends ItemView {
readFile(this.app, normalizePath(`${projectPath(p.name)}/${CORE_FILE}`)), 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" }); const grid = root.createDiv({ cls: "pk-grid" });
for (let i = 0; i < projects.length; i++) { for (let i = 0; i < projects.length; i++) {
@@ -141,21 +149,13 @@ export class ProjectsView extends ItemView {
}).open(); }).open();
} }
private openDelete(name: string): void { private async openDelete(name: string): Promise<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 { try {
await deleteRecursive(this.app, projectPath(name)); await deleteRecursive(this.app, projectPath(name));
await this.render(); await this.render();
} catch (e) { } catch (e) {
new Notice(`Fehler: ${(e as Error).message}`); new Notice(`Fehler: ${(e as Error).message}`);
} }
},
}).open();
} }
private async openOverview(name: string): Promise<void> { private async openOverview(name: string): Promise<void> {

View File

@@ -3,6 +3,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
container-type: inline-size;
} }
.pk-toolbar { .pk-toolbar {
@@ -23,10 +24,32 @@
gap: 10px; gap: 10px;
} }
.pk-stack { .pk-features-section {
display: flex; display: flex;
flex-direction: column; 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 { .pk-card {
@@ -136,6 +159,18 @@
font-weight: 400; 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 { .pk-btn-card {
box-sizing: border-box; box-sizing: border-box;
padding: 10px; padding: 10px;
@@ -157,6 +192,20 @@
font-weight: 600; 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 { .pk-drop-target {
outline: 2px dashed var(--text-accent); outline: 2px dashed var(--text-accent);
@@ -175,16 +224,15 @@
.pk-areas-section { .pk-areas-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 5px;
padding: 5px;
} }
.pk-areas-flex { .pk-areas-flex {
column-width: 250px; column-width: 200px;
column-gap: 10px; column-gap: 5px;
} }
.pk-area-card { .pk-area-card {
break-inside: avoid; break-inside: avoid;
margin-bottom: 10px; margin-bottom: 5px;
} }