Compare commits
2 Commits
d531bc663d
...
6ea56fb5e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea56fb5e0 | ||
|
|
a723f1ea0f |
1
doc/_core.md
Normal file
1
doc/_core.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Obsidian Plugin - Projektkontext managen
|
||||||
24
doc/_project.md
Normal file
24
doc/_project.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
**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
|
||||||
7
doc/_story.md
Normal file
7
doc/_story.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
- 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
|
||||||
2
doc/_target.md
Normal file
2
doc/_target.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
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.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
- container für features
|
- container für features
|
||||||
- Auflistung als Columns
|
- Auflistung als Columns
|
||||||
- wird gespeichert als ./{area}/
|
- wird gespeichert als `./{area}/`
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
- Max 64 Zeichen
|
|
||||||
- wird als ./core.md gespeichert
|
|
||||||
- beschreibt kompakt was das projekt ist
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
- kann ich projekt oder subprojekt zu einem großen projekt sein
|
|
||||||
- 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
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
- 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?
|
|
||||||
- der zweck muss klar erkennbar sein
|
|
||||||
- max 255 zeichen
|
|
||||||
- wird als ./target.md gespeichert
|
|
||||||
@@ -1 +1,3 @@
|
|||||||
Obsidian Plugin - Projektkontext managen
|
- Max 64 Zeichen
|
||||||
|
- wird als `./_core.md` gespeichert
|
||||||
|
- beschreibt kompakt was das projekt ist
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ layout: features
|
|||||||
|
|
||||||
features
|
features
|
||||||
- alle features zum area als cards
|
- alle features zum area als cards
|
||||||
- LMB auf feature öffnet die {feature}.md
|
- LMB auf feature öffnet die `{feature}.md`
|
||||||
- RMB auf feature öffnet die feature optionen
|
- RMB auf feature öffnet die feature optionen
|
||||||
|
|
||||||
feature option create
|
feature option create
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
- beschreibt eine funktionalität der area
|
- beschreibt eine funktionalität der area
|
||||||
- wie ein git commit
|
- wie ein git commit
|
||||||
- wird gespeichert als ./{area}/{feature}.md
|
- wird gespeichert als `./{area}/{feature}.md`
|
||||||
@@ -1,24 +1,9 @@
|
|||||||
**Core**
|
- kann ich projekt oder subprojekt zu einem großen projekt sein
|
||||||
- Obsidian Plugin - Projektkontext managen
|
- ist abgeschlossen, also hat keine abhängigkeit
|
||||||
**Target**
|
- ist der root ordner
|
||||||
- Für große Projekte geht der Kontext schnell verloren.
|
- ordnername ist der projektname
|
||||||
- Sie werden unübersichtlich und schwer zu managen.
|
|
||||||
- Das Plugin hilft Projekte kontextbezogen zu strukturieren.
|
kerndatei
|
||||||
- So lassen sich große Projekte wieder managen.
|
- wird als project.md gespeichert
|
||||||
**Story**
|
- enthält kern, zweck, stories und kategorien als md dateien
|
||||||
- Großes Projekt soll geplannt werden
|
- enthält bereiche als ordner
|
||||||
- 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
|
|
||||||
|
|||||||
12
doc/story.md
12
doc/story.md
@@ -1,7 +1,5 @@
|
|||||||
- Großes Projekt soll geplannt werden
|
- beschreibt wie das fertige projekt in der regel verwendet werden soll
|
||||||
- Core definieren (Worum geht es?)
|
- Max 1024 Zeichen
|
||||||
- Target beschreiben (Welches Problem löst es?)
|
- wird als `./_story.md` gespeichert
|
||||||
- Story schreiben (Wie wird es verwendet?)
|
- nur die hauptanwendung
|
||||||
- Areas und Features definieren (Welche Funktionen hat es?)
|
- abfolge von schritten (start ... ende)
|
||||||
- Features implementieren
|
|
||||||
- Projekt v1
|
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
Für große Projekte geht der Kontext schnell verloren, sie werden unübersichtlich und schwer zu managen.
|
- beschreibt simpel welches problem das projekt löst
|
||||||
Das Plugin hilft Projekte kontextbezogen zu strukturieren, so lassen sich große Projekte wieder managen.
|
- Was ist das Problem und die Folge davon?
|
||||||
|
- Wie löst das Projekt das Problem und was ist die Folge davon?
|
||||||
|
- der zweck muss klar erkennbar sein
|
||||||
|
- max 255 zeichen
|
||||||
|
- wird als `./_target.md` gespeichert
|
||||||
|
|||||||
@@ -6,4 +6,9 @@ export const VIEW_TYPE_DETAILS = "projektkontext-details";
|
|||||||
|
|
||||||
export const RIBBON_ICON = "layout-grid";
|
export const RIBBON_ICON = "layout-grid";
|
||||||
|
|
||||||
export const PROJECT_FILES = ["core.md", "target.md", "story.md"] as const;
|
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;
|
||||||
|
|||||||
13
src/fs.ts
13
src/fs.ts
@@ -94,6 +94,19 @@ export async function createFeature(
|
|||||||
return await ensureFile(app, featurePath(project, area, feature), "");
|
return await ensureFile(app, featurePath(project, area, feature), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function projectFeaturePath(project: string, feature: string): string {
|
||||||
|
const file = feature.endsWith(".md") ? feature : `${feature}.md`;
|
||||||
|
return normalizePath(`${PROJECTS_ROOT}/${project}/${file}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectFeature(
|
||||||
|
app: App,
|
||||||
|
project: string,
|
||||||
|
feature: string,
|
||||||
|
): Promise<TFile> {
|
||||||
|
return await ensureFile(app, projectFeaturePath(project, feature), "");
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { ItemView, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
|
import { ItemView, Notice, WorkspaceLeaf, ViewStateResult, normalizePath } from "obsidian";
|
||||||
import {
|
import {
|
||||||
VIEW_TYPE_OVERVIEW,
|
VIEW_TYPE_OVERVIEW,
|
||||||
VIEW_TYPE_DETAILS,
|
VIEW_TYPE_DETAILS,
|
||||||
VIEW_TYPE_PROJECTS,
|
VIEW_TYPE_PROJECTS,
|
||||||
RIBBON_ICON,
|
RIBBON_ICON,
|
||||||
|
PROJECT_FILES,
|
||||||
|
CORE_FILE,
|
||||||
|
TARGET_FILE,
|
||||||
|
STORY_FILE,
|
||||||
} from "../const";
|
} from "../const";
|
||||||
import {
|
import {
|
||||||
projectPath,
|
projectPath,
|
||||||
areaPath,
|
areaPath,
|
||||||
featurePath,
|
featurePath,
|
||||||
|
projectFeaturePath,
|
||||||
listFolders,
|
listFolders,
|
||||||
listMarkdownFiles,
|
listMarkdownFiles,
|
||||||
readFile,
|
readFile,
|
||||||
@@ -16,8 +21,9 @@ import {
|
|||||||
deleteRecursive,
|
deleteRecursive,
|
||||||
createArea,
|
createArea,
|
||||||
createFeature,
|
createFeature,
|
||||||
|
createProjectFeature,
|
||||||
} from "../fs";
|
} from "../fs";
|
||||||
import { card, menu, breadcrumb, emptyState, openMarkdown } from "../ui";
|
import { menu, breadcrumb, emptyState, openMarkdown } from "../ui";
|
||||||
import { NameModal } from "../modals/NameModal";
|
import { NameModal } from "../modals/NameModal";
|
||||||
import { ConfirmModal } from "../modals/ConfirmModal";
|
import { ConfirmModal } from "../modals/ConfirmModal";
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ export interface OverviewState extends Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
const NAME_RX = /^[^\\/:*?"<>|]+$/;
|
||||||
|
const FEATURE_DND_MIME = "application/x-pk-feature";
|
||||||
|
|
||||||
function validateName(name: string, taken: string[], current?: string): string | null {
|
function validateName(name: string, taken: string[], current?: string): string | null {
|
||||||
if (!name) return "Name darf nicht leer sein.";
|
if (!name) return "Name darf nicht leer sein.";
|
||||||
@@ -89,9 +96,9 @@ export class OverviewView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projRoot = projectPath(this.project);
|
const projRoot = projectPath(this.project);
|
||||||
const corePath = normalizePath(`${projRoot}/core.md`);
|
const corePath = normalizePath(`${projRoot}/${CORE_FILE}`);
|
||||||
const targetPath = normalizePath(`${projRoot}/target.md`);
|
const targetPath = normalizePath(`${projRoot}/${TARGET_FILE}`);
|
||||||
const storyPath = normalizePath(`${projRoot}/story.md`);
|
const storyPath = normalizePath(`${projRoot}/${STORY_FILE}`);
|
||||||
const [core, target, story] = 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),
|
||||||
@@ -108,22 +115,24 @@ export class OverviewView extends ItemView {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const stack = root.createDiv({ cls: "pk-stack" });
|
const stack = root.createDiv({ cls: "pk-stack" });
|
||||||
this.renderInfoCard(stack, "Core", core, corePath);
|
this.renderInfoCard(stack, core, corePath);
|
||||||
this.renderInfoCard(stack, "Target", target, targetPath);
|
this.renderInfoCard(stack, target, targetPath);
|
||||||
this.renderStoryCard(stack, story, storyPath);
|
this.renderStoryCard(stack, story, storyPath);
|
||||||
this.renderAreas(root);
|
this.renderAreas(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderInfoCard(parent: HTMLElement, title: string, content: string, path: string): void {
|
private renderInfoCard(parent: HTMLElement, content: string, path: string): void {
|
||||||
card(parent, {
|
const btn = parent.createDiv({
|
||||||
title,
|
cls: "pk-btn-card",
|
||||||
body: content || "(leer)",
|
attr: { role: "button", tabindex: "0" },
|
||||||
onClick: () =>
|
});
|
||||||
|
btn.setText(content || "(leer)");
|
||||||
|
btn.addEventListener("click", () =>
|
||||||
openMarkdown(this.app, path, {
|
openMarkdown(this.app, path, {
|
||||||
type: VIEW_TYPE_OVERVIEW,
|
type: VIEW_TYPE_OVERVIEW,
|
||||||
state: { project: this.project },
|
state: { project: this.project },
|
||||||
}),
|
}),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderStoryCard(parent: HTMLElement, content: string, path: string): void {
|
private renderStoryCard(parent: HTMLElement, content: string, path: string): void {
|
||||||
@@ -154,12 +163,36 @@ export class OverviewView extends ItemView {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
menu(ev, [
|
menu(ev, [
|
||||||
{ title: "Neue Area", icon: "plus", onClick: () => this.openCreateArea() },
|
{ title: "Neue Area", icon: "plus", onClick: () => this.openCreateArea() },
|
||||||
|
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateProjectFeature() },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
section.addEventListener("dragover", (ev) => {
|
||||||
|
if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
|
section.addClass("pk-drop-zone");
|
||||||
|
});
|
||||||
|
section.addEventListener("dragleave", (ev) => {
|
||||||
|
const next = ev.relatedTarget as Node | null;
|
||||||
|
if (next && section.contains(next)) return;
|
||||||
|
section.removeClass("pk-drop-zone");
|
||||||
|
});
|
||||||
|
section.addEventListener("drop", async (ev) => {
|
||||||
|
section.removeClass("pk-drop-zone");
|
||||||
|
const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME);
|
||||||
|
if (!raw) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
await this.handleFeatureDropToProject(raw);
|
||||||
|
});
|
||||||
|
|
||||||
const areas = listFolders(this.app, projectPath(this.project));
|
const areas = listFolders(this.app, projectPath(this.project));
|
||||||
if (areas.length === 0) {
|
const projectFeatures = listMarkdownFiles(
|
||||||
emptyState(section, "Noch keine Areas. Rechtsklick → Neue Area.");
|
this.app,
|
||||||
|
projectPath(this.project),
|
||||||
|
[...PROJECT_FILES],
|
||||||
|
);
|
||||||
|
if (areas.length === 0 && projectFeatures.length === 0) {
|
||||||
|
emptyState(section, "Noch leer. Rechtsklick → Neue Area / Neues Feature.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const takenAreas = areas.map((a) => a.name);
|
const takenAreas = areas.map((a) => a.name);
|
||||||
@@ -170,24 +203,59 @@ export class OverviewView extends ItemView {
|
|||||||
areaPath(this.project, area.name),
|
areaPath(this.project, area.name),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
card(stack, {
|
const areaCard = stack.createDiv({
|
||||||
title: area.name,
|
cls: "pk-btn-card pk-area-card",
|
||||||
cls: "pk-area-card",
|
attr: { role: "button", tabindex: "0" },
|
||||||
onClick: () => this.openDetails(area.name),
|
});
|
||||||
onContextMenu: (ev) =>
|
areaCard.createEl("strong", { text: area.name });
|
||||||
|
areaCard.addEventListener("click", () => this.openDetails(area.name));
|
||||||
|
areaCard.addEventListener("contextmenu", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
menu(ev, [
|
menu(ev, [
|
||||||
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeature(area.name) },
|
{ title: "Neues Feature", icon: "plus", onClick: () => this.openCreateFeature(area.name) },
|
||||||
{ title: "Area umbenennen", icon: "pencil", onClick: () => this.openRenameArea(area.name, takenAreas) },
|
{ title: "Area umbenennen", icon: "pencil", onClick: () => this.openRenameArea(area.name, takenAreas) },
|
||||||
{ title: "Area löschen", icon: "trash", onClick: () => this.openDeleteArea(area.name) },
|
{ title: "Area löschen", icon: "trash", onClick: () => this.openDeleteArea(area.name) },
|
||||||
]),
|
]);
|
||||||
body: (body) => {
|
});
|
||||||
|
areaCard.addEventListener("dragover", (ev) => {
|
||||||
|
if (!ev.dataTransfer?.types.includes(FEATURE_DND_MIME)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = "move";
|
||||||
|
areaCard.addClass("pk-drop-target");
|
||||||
|
});
|
||||||
|
areaCard.addEventListener("dragleave", (ev) => {
|
||||||
|
const next = ev.relatedTarget as Node | null;
|
||||||
|
if (next && areaCard.contains(next)) return;
|
||||||
|
areaCard.removeClass("pk-drop-target");
|
||||||
|
});
|
||||||
|
areaCard.addEventListener("drop", async (ev) => {
|
||||||
|
areaCard.removeClass("pk-drop-target");
|
||||||
|
const raw = ev.dataTransfer?.getData(FEATURE_DND_MIME);
|
||||||
|
if (!raw) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
await this.handleFeatureDrop(raw, area.name);
|
||||||
|
});
|
||||||
if (features.length === 0) {
|
if (features.length === 0) {
|
||||||
body.createDiv({ cls: "pk-empty", text: "Keine Features." });
|
areaCard.createDiv({ cls: "pk-empty", text: "Keine Features." });
|
||||||
return;
|
} else {
|
||||||
}
|
const list = areaCard.createDiv({ cls: "pk-features" });
|
||||||
const list = body.createDiv({ cls: "pk-features" });
|
|
||||||
for (const f of features) {
|
for (const f of features) {
|
||||||
const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename });
|
const chip = list.createDiv({ cls: "pk-feature-chip", text: f.basename });
|
||||||
|
chip.draggable = true;
|
||||||
|
chip.addEventListener("dragstart", (ev) => {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
ev.dataTransfer.setData(
|
||||||
|
FEATURE_DND_MIME,
|
||||||
|
JSON.stringify({ sourceArea: area.name, feature: f.basename }),
|
||||||
|
);
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
chip.addClass("pk-feature-chip-dragging");
|
||||||
|
});
|
||||||
|
chip.addEventListener("dragend", () => {
|
||||||
|
chip.removeClass("pk-feature-chip-dragging");
|
||||||
|
});
|
||||||
chip.addEventListener("click", (ev) => {
|
chip.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
openMarkdown(this.app, f.path, {
|
openMarkdown(this.app, f.path, {
|
||||||
@@ -203,11 +271,120 @@ export class OverviewView extends ItemView {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of projectFeatures) {
|
||||||
|
const fc = stack.createDiv({
|
||||||
|
cls: "pk-btn-card pk-area-card",
|
||||||
|
attr: { role: "button", tabindex: "0", draggable: "true" },
|
||||||
|
});
|
||||||
|
fc.createEl("strong", { text: f.basename });
|
||||||
|
fc.addEventListener("dragstart", (ev) => {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
ev.dataTransfer.setData(
|
||||||
|
FEATURE_DND_MIME,
|
||||||
|
JSON.stringify({ sourceArea: null, feature: f.basename }),
|
||||||
|
);
|
||||||
|
ev.dataTransfer.effectAllowed = "move";
|
||||||
|
fc.addClass("pk-feature-chip-dragging");
|
||||||
|
});
|
||||||
|
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("contextmenu", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
menu(ev, [
|
||||||
|
{ title: "Feature löschen", icon: "trash", onClick: () => this.openDeleteProjectFeature(f.basename) },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openCreateProjectFeature(): void {
|
||||||
|
const taken = listMarkdownFiles(this.app, projectPath(this.project), []).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);
|
||||||
|
await this.render();
|
||||||
|
},
|
||||||
|
}).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 handleFeatureDropToProject(raw: string): Promise<void> {
|
||||||
|
let data: { sourceArea: string | null; feature: string };
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data?.feature) return;
|
||||||
|
if (data.sourceArea === null) return;
|
||||||
|
const newPath = projectFeaturePath(this.project, data.feature);
|
||||||
|
if (this.app.vault.getAbstractFileByPath(newPath)) {
|
||||||
|
new Notice(`„${data.feature}" existiert im Projekt bereits.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldPath = featurePath(this.project, data.sourceArea, data.feature);
|
||||||
|
try {
|
||||||
|
await rename(this.app, oldPath, newPath);
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleFeatureDrop(raw: string, targetArea: string): Promise<void> {
|
||||||
|
let data: { sourceArea: string | null; feature: string };
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data?.feature) return;
|
||||||
|
if (data.sourceArea === targetArea) return;
|
||||||
|
const newPath = featurePath(this.project, targetArea, data.feature);
|
||||||
|
if (this.app.vault.getAbstractFileByPath(newPath)) {
|
||||||
|
new Notice(`„${data.feature}" existiert in „${targetArea}" bereits.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldPath = data.sourceArea === null
|
||||||
|
? projectFeaturePath(this.project, data.feature)
|
||||||
|
: featurePath(this.project, data.sourceArea, data.feature);
|
||||||
|
try {
|
||||||
|
await rename(this.app, oldPath, newPath);
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(`Verschieben fehlgeschlagen: ${(e as Error).message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.render();
|
||||||
|
}
|
||||||
|
|
||||||
private openCreateArea(): void {
|
private openCreateArea(): void {
|
||||||
const taken = listFolders(this.app, projectPath(this.project)).map((a) => a.name);
|
const taken = listFolders(this.app, projectPath(this.project)).map((a) => a.name);
|
||||||
new NameModal(this.app, {
|
new NameModal(this.app, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
|
import { ItemView, Notice, WorkspaceLeaf, normalizePath } from "obsidian";
|
||||||
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON } from "../const";
|
import { VIEW_TYPE_PROJECTS, VIEW_TYPE_OVERVIEW, RIBBON_ICON, CORE_FILE } from "../const";
|
||||||
import {
|
import {
|
||||||
projectsPath,
|
projectsPath,
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -8,8 +8,9 @@ import {
|
|||||||
createProject,
|
createProject,
|
||||||
rename,
|
rename,
|
||||||
deleteRecursive,
|
deleteRecursive,
|
||||||
|
readFile,
|
||||||
} from "../fs";
|
} from "../fs";
|
||||||
import { card, menu, breadcrumb, emptyState } from "../ui";
|
import { menu, breadcrumb, emptyState } from "../ui";
|
||||||
import { NameModal } from "../modals/NameModal";
|
import { NameModal } from "../modals/NameModal";
|
||||||
import { ConfirmModal } from "../modals/ConfirmModal";
|
import { ConfirmModal } from "../modals/ConfirmModal";
|
||||||
|
|
||||||
@@ -45,6 +46,11 @@ export class ProjectsView extends ItemView {
|
|||||||
this.registerEvent(this.app.vault.on("create", () => this.render()));
|
this.registerEvent(this.app.vault.on("create", () => this.render()));
|
||||||
this.registerEvent(this.app.vault.on("delete", () => this.render()));
|
this.registerEvent(this.app.vault.on("delete", () => this.render()));
|
||||||
this.registerEvent(this.app.vault.on("rename", () => this.render()));
|
this.registerEvent(this.app.vault.on("rename", () => this.render()));
|
||||||
|
this.registerEvent(this.app.vault.on("modify", (f) => {
|
||||||
|
if (f.path.endsWith("/" + CORE_FILE) && f.path.startsWith(projectsPath() + "/")) {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}));
|
||||||
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
|
this.registerDomEvent(this.containerEl, "contextmenu", (ev) => {
|
||||||
if (ev.defaultPrevented) return;
|
if (ev.defaultPrevented) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -70,16 +76,29 @@ export class ProjectsView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const taken = projects.map((p) => p.name);
|
const taken = projects.map((p) => p.name);
|
||||||
|
const cores = await Promise.all(
|
||||||
|
projects.map((p) =>
|
||||||
|
readFile(this.app, normalizePath(`${projectPath(p.name)}/${CORE_FILE}`)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const grid = root.createDiv({ cls: "pk-grid" });
|
const grid = root.createDiv({ cls: "pk-grid" });
|
||||||
for (const proj of projects) {
|
for (let i = 0; i < projects.length; i++) {
|
||||||
card(grid, {
|
const proj = projects[i];
|
||||||
title: proj.name,
|
const core = cores[i].trim();
|
||||||
onClick: () => this.openOverview(proj.name),
|
const btn = grid.createDiv({
|
||||||
onContextMenu: (ev) =>
|
cls: "pk-btn-card",
|
||||||
|
attr: { role: "button", tabindex: "0" },
|
||||||
|
});
|
||||||
|
btn.createEl("strong", { text: proj.name });
|
||||||
|
if (core) btn.createDiv({ text: core });
|
||||||
|
btn.addEventListener("click", () => this.openOverview(proj.name));
|
||||||
|
btn.addEventListener("contextmenu", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
menu(ev, [
|
menu(ev, [
|
||||||
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) },
|
{ title: "Umbenennen", icon: "pencil", onClick: () => this.openRename(proj.name, taken) },
|
||||||
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) },
|
{ title: "Löschen", icon: "trash", onClick: () => this.openDelete(proj.name) },
|
||||||
]),
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
styles.css
37
styles.css
@@ -136,10 +136,47 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pk-btn-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--background-secondary);
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-card:hover {
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-card strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pk-drop-target {
|
||||||
|
outline: 2px dashed var(--text-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-drop-zone {
|
||||||
|
background: var(--background-modifier-hover);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-feature-chip-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
.pk-areas-section {
|
.pk-areas-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pk-areas-flex {
|
.pk-areas-flex {
|
||||||
|
|||||||
Reference in New Issue
Block a user