feat(fluent): add project creation UI with sorting capabilities

- Add CustomProject interface for project persistence
- Implement ProjectPopover (desktop) and ProjectModal (mobile) for project creation
- Add color picker with 15 predefined colors for visual organization
- Replace add button with sort menu in sidebar
- Implement 6 sort options (name, tasks, created date) with localStorage persistence
- Use Intl.Collator for locale-sensitive string comparison
- Extend Obsidian Component class for proper lifecycle management
- Add comprehensive CSS styling for popover and modal interfaces
This commit is contained in:
Quorafind 2025-09-22 11:55:06 +08:00
parent c747e2bb14
commit 9f008db1a0
11 changed files with 1456 additions and 29 deletions

View file

@ -509,6 +509,15 @@ export interface ProjectNamingStrategy {
enabled: boolean;
}
/** Custom project definition for V2 */
export interface CustomProject {
id: string;
name: string;
color: string;
createdAt: number;
updatedAt: number;
}
/** Enhanced project configuration */
export interface ProjectConfiguration {
/** Path-based project mappings */
@ -523,6 +532,8 @@ export interface ProjectConfiguration {
metadataMappings: MetadataMapping[];
/** Default project naming strategy */
defaultProjectNaming: ProjectNamingStrategy;
/** Custom projects for V2 */
customProjects?: CustomProject[];
}
/** File parsing configuration for extracting tasks from file metadata and tags */

View file

@ -365,6 +365,40 @@ export class KanbanComponent extends Component {
});
}
// Allow multiple symbols to represent the same logical status (e.g., In Progress: "/" and ">")
private getAllowedMarksForStatusName(
statusName: string
): Set<string> | null {
if (!statusName) return null;
const s = statusName.trim().toLowerCase();
const ts = this.plugin.settings.taskStatuses as any;
if (!ts) return null;
// Map common status names to configured categories
const keyMap: Record<string, string> = {
"in progress": "inProgress",
completed: "completed",
abandoned: "abandoned",
planned: "planned",
"not started": "notStarted",
// synonyms commonly seen
cancelled: "abandoned",
canceled: "abandoned",
unchecked: "notStarted",
checked: "completed",
};
const key = keyMap[s];
if (!key) return null;
const raw: string | undefined = ts[key];
if (!raw || typeof raw !== "string") return null;
const set = new Set(
raw
.split("|")
.map((ch: string) => ch.trim())
.filter((ch: string) => ch.length === 1)
);
return set.size > 0 ? set : null;
}
// Handle filter application from clickable metadata
private handleFilterApply = (
filterType: string,
@ -722,19 +756,18 @@ export class KanbanComponent extends Component {
}
private getTasksForStatus(statusName: string): Task[] {
// Prefer multi-mark mapping from settings.taskStatuses when available
const allowed = this.getAllowedMarksForStatusName(statusName);
const statusMark = this.resolveStatusMark(statusName) ?? " ";
// Filter from the already filtered list
const tasksForStatus = this.tasks.filter((task) => {
const taskStatusMark = task.status || " ";
return taskStatusMark === statusMark;
const mark = task.status || " ";
return allowed ? allowed.has(mark) : mark === statusMark;
});
// Sort tasks within the status column based on selected sort option
tasksForStatus.sort((a, b) => {
return this.compareTasks(a, b, this.sortOption);
});
tasksForStatus.sort((a, b) => this.compareTasks(a, b, this.sortOption));
return tasksForStatus;
}

View file

@ -15,6 +15,7 @@ import { V2Sidebar } from "./components/V2Sidebar";
import "./styles/v2.css";
import "./styles/v2-enhanced.css";
import "./styles/v2-content-header.css";
import "@/styles/v2-project-popover.css";
import { TopNavigation, ViewMode } from "./components/V2TopNavigation";
import { Workspace, V2ViewState } from "./types";
import { TaskListItemComponent } from "../../components/features/task/view/listItem";
@ -319,6 +320,8 @@ export class TaskViewV2 extends ItemView {
(workspace) => this.handleWorkspaceChange(workspace),
(projectId) => this.handleProjectSelect(projectId)
);
// Add sidebar as a child component for proper lifecycle management
this.addChild(this.sidebar);
}
private initializeTopNavigation(containerEl: HTMLElement) {
@ -973,6 +976,11 @@ export class TaskViewV2 extends ItemView {
"[TG-V2] Using renderContentWithViewMode for non-list/tree mode:",
this.viewState.viewMode
);
// BUGFIX: ensure filters are applied for the newly selected view (e.g., Today/Upcoming)
console.log(
"[TG-V2] Applying filters before renderContentWithViewMode"
);
this.applyFilters();
this.renderContentWithViewMode();
return;
}

View file

@ -1,32 +1,72 @@
import { setIcon } from "obsidian";
import { Component, Platform, setIcon, Menu } from "obsidian";
import TaskProgressBarPlugin from "../../../index";
import { Task } from "../../../types/task";
import { ProjectPopover, ProjectModal } from "./ProjectPopover";
import type { CustomProject } from "../../../common/setting-definition";
interface Project {
id: string;
name: string;
color: string;
taskCount: number;
createdAt?: number;
updatedAt?: number;
}
export class ProjectList {
type SortOption =
| "name-asc"
| "name-desc"
| "tasks-asc"
| "tasks-desc"
| "created-asc"
| "created-desc";
export class ProjectList extends Component {
private containerEl: HTMLElement;
private plugin: TaskProgressBarPlugin;
private projects: Project[] = [];
private activeProjectId: string | null = null;
private onProjectSelect: (projectId: string) => void;
private currentPopover: ProjectPopover | null = null;
private currentSort: SortOption = "name-asc";
private readonly STORAGE_KEY = "task-genius-project-sort";
private collator: Intl.Collator;
constructor(
containerEl: HTMLElement,
plugin: TaskProgressBarPlugin,
private onProjectSelect: (projectId: string) => void
onProjectSelect: (projectId: string) => void
) {
super();
this.containerEl = containerEl;
this.plugin = plugin;
this.onProjectSelect = onProjectSelect;
this.loadProjects();
// Initialize collator with locale-sensitive sorting
// Use numeric option to handle numbers naturally (e.g., "Project 2" < "Project 10")
this.collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base", // Case-insensitive comparison
});
}
async onload() {
await this.loadSortPreference();
await this.loadProjects();
this.render();
}
onunload() {
// Clean up any open popover
if (this.currentPopover) {
this.removeChild(this.currentPopover);
this.currentPopover = null;
}
// Clear container
this.containerEl.empty();
}
private async loadProjects() {
let tasks: Task[] = [];
if (this.plugin.dataflowOrchestrator) {
@ -56,9 +96,62 @@ export class ProjectList {
});
this.projects = Array.from(projectMap.values());
// Load custom projects
this.loadCustomProjects();
// Apply sorting
this.sortProjects();
this.render();
}
private async loadSortPreference() {
const saved = await this.plugin.app.loadLocalStorage(this.STORAGE_KEY);
if (saved && this.isValidSortOption(saved)) {
this.currentSort = saved as SortOption;
}
}
private async saveSortPreference() {
await this.plugin.app.saveLocalStorage(
this.STORAGE_KEY,
this.currentSort
);
}
private isValidSortOption(value: string): boolean {
return [
"name-asc",
"name-desc",
"tasks-asc",
"tasks-desc",
"created-asc",
"created-desc",
].includes(value);
}
private sortProjects() {
this.projects.sort((a, b) => {
switch (this.currentSort) {
case "name-asc":
return this.collator.compare(a.name, b.name);
case "name-desc":
return this.collator.compare(b.name, a.name);
case "tasks-asc":
return a.taskCount - b.taskCount;
case "tasks-desc":
return b.taskCount - a.taskCount;
case "created-asc":
return (a.createdAt || 0) - (b.createdAt || 0);
case "created-desc":
return (b.createdAt || 0) - (a.createdAt || 0);
default:
return 0;
}
});
}
private generateColorForProject(projectName: string): string {
const colors = [
"#e74c3c",
@ -112,7 +205,7 @@ export class ProjectList {
text: String(project.taskCount),
});
projectItem.addEventListener("click", () => {
this.registerDomEvent(projectItem, "click", () => {
this.setActiveProject(project.id);
this.onProjectSelect(project.id);
});
@ -131,8 +224,8 @@ export class ProjectList {
text: "Add Project",
});
addProjectBtn.addEventListener("click", () => {
console.log("Add new project");
this.registerDomEvent(addProjectBtn, "click", () => {
this.handleAddProject(addProjectBtn);
});
}
@ -160,4 +253,175 @@ export class ProjectList {
public refresh() {
this.loadProjects();
}
private handleAddProject(buttonEl: HTMLElement) {
// Clean up any existing popover
if (this.currentPopover) {
this.removeChild(this.currentPopover);
this.currentPopover = null;
}
if (Platform.isMobile) {
// Mobile: Use Obsidian Modal
const modal = new ProjectModal(
this.plugin.app,
this.plugin,
async (project) => {
await this.saveProject(project);
}
);
modal.open();
} else {
// Desktop: Use popover
this.currentPopover = new ProjectPopover(
this.plugin,
buttonEl,
async (project) => {
await this.saveProject(project);
if (this.currentPopover) {
this.removeChild(this.currentPopover);
this.currentPopover = null;
}
},
() => {
if (this.currentPopover) {
this.removeChild(this.currentPopover);
this.currentPopover = null;
}
}
);
this.addChild(this.currentPopover);
}
}
private async saveProject(project: CustomProject) {
// Initialize customProjects if it doesn't exist
if (!this.plugin.settings.projectConfig) {
this.plugin.settings.projectConfig = {
enableEnhancedProject: false,
pathMappings: [],
metadataConfig: {
metadataKey: "project",
enabled: false,
},
configFile: {
fileName: "project.md",
searchRecursively: true,
enabled: false,
},
metadataMappings: [],
defaultProjectNaming: {
strategy: "filename",
stripExtension: true,
enabled: false,
},
customProjects: [],
};
}
if (!this.plugin.settings.projectConfig.customProjects) {
this.plugin.settings.projectConfig.customProjects = [];
}
// Add the new project
this.plugin.settings.projectConfig.customProjects.push(project);
// Save settings
await this.plugin.saveSettings();
// Refresh the project list
this.loadProjects();
}
private loadCustomProjects() {
const customProjects =
this.plugin.settings.projectConfig?.customProjects || [];
// Merge custom projects into the projects array
customProjects.forEach((customProject) => {
// Check if project already exists by name
const existingIndex = this.projects.findIndex(
(p) => p.name === customProject.name
);
if (existingIndex === -1) {
// Add new custom project
this.projects.push({
id: customProject.id,
name: customProject.name,
color: customProject.color,
taskCount: 0, // Will be updated by task counting
createdAt: customProject.createdAt,
updatedAt: customProject.updatedAt,
});
} else {
// Update existing project with custom color
this.projects[existingIndex].id = customProject.id;
this.projects[existingIndex].color = customProject.color;
this.projects[existingIndex].createdAt =
customProject.createdAt;
this.projects[existingIndex].updatedAt =
customProject.updatedAt;
}
});
}
private showSortMenu(buttonEl: HTMLElement) {
const menu = new Menu();
const sortOptions: {
label: string;
value: SortOption;
icon: string;
}[] = [
{ label: "Name (A-Z)", value: "name-asc", icon: "arrow-up-a-z" },
{ label: "Name (Z-A)", value: "name-desc", icon: "arrow-down-a-z" },
{
label: "Tasks (Low to High)",
value: "tasks-asc",
icon: "arrow-up-1-0",
},
{
label: "Tasks (High to Low)",
value: "tasks-desc",
icon: "arrow-down-1-0",
},
{
label: "Created (Oldest First)",
value: "created-asc",
icon: "clock",
},
{
label: "Created (Newest First)",
value: "created-desc",
icon: "history",
},
];
sortOptions.forEach((option) => {
menu.addItem((item) => {
item.setTitle(option.label)
.setIcon(option.icon)
.onClick(async () => {
this.currentSort = option.value;
await this.saveSortPreference();
this.sortProjects();
this.render();
});
if (this.currentSort === option.value) {
item.setChecked(true);
}
});
});
menu.showAtMouseEvent(
new MouseEvent("click", {
view: window,
bubbles: true,
cancelable: true,
clientX: buttonEl.getBoundingClientRect().left,
clientY: buttonEl.getBoundingClientRect().bottom,
})
);
}
}

View file

@ -0,0 +1,441 @@
import { Component, Platform, Modal, App } from "obsidian";
import { createPopper, Instance as PopperInstance } from "@popperjs/core";
import TaskProgressBarPlugin from "../../../index";
import type { CustomProject } from "../../../common/setting-definition";
export class ProjectPopover extends Component {
private popoverEl: HTMLElement | null = null;
private plugin: TaskProgressBarPlugin;
private onSave: (project: CustomProject) => void;
private onClose: () => void;
private nameInput: HTMLInputElement | null = null;
private selectedColor: string = "#3498db";
private popperInstance: PopperInstance | null = null;
private referenceEl: HTMLElement;
private clickHandler: ((e: MouseEvent) => void) | null = null;
private escapeHandler: ((e: KeyboardEvent) => void) | null = null;
private readonly colors = [
"#e74c3c",
"#3498db",
"#2ecc71",
"#f39c12",
"#9b59b6",
"#1abc9c",
"#34495e",
"#e67e22",
"#16a085",
"#27ae60",
"#2980b9",
"#8e44ad",
"#2c3e50",
"#f1c40f",
"#d35400"
];
constructor(
plugin: TaskProgressBarPlugin,
referenceEl: HTMLElement,
onSave: (project: CustomProject) => void,
onClose: () => void
) {
super();
this.plugin = plugin;
this.referenceEl = referenceEl;
this.onSave = onSave;
this.onClose = onClose;
}
onload() {
this.createPopover();
this.setupPopper();
this.setupEventHandlers();
}
onunload() {
this.cleanup();
}
private createPopover() {
this.popoverEl = document.createElement("div");
this.popoverEl.addClass("v2-project-popover-container");
document.body.appendChild(this.popoverEl);
const popover = this.popoverEl.createDiv({ cls: "v2-project-popover" });
// Arrow
const arrow = popover.createDiv({ cls: "v2-popover-arrow" });
arrow.setAttribute("data-popper-arrow", "");
// Content
const content = popover.createDiv({ cls: "v2-popover-content" });
// Title
const header = content.createDiv({ cls: "v2-popover-header" });
header.createEl("h3", { text: "New Project" });
// Name input
const nameSection = content.createDiv({ cls: "v2-popover-section" });
nameSection.createEl("label", { text: "Project Name" });
this.nameInput = nameSection.createEl("input", {
cls: "v2-popover-input",
attr: {
type: "text",
placeholder: "Enter project name",
}
});
// Color picker
const colorSection = content.createDiv({ cls: "v2-popover-section" });
colorSection.createEl("label", { text: "Choose Color" });
const colorGrid = colorSection.createDiv({ cls: "v2-color-grid" });
this.colors.forEach(color => {
const colorButton = colorGrid.createDiv({
cls: "v2-color-button",
attr: { "data-color": color }
});
colorButton.style.backgroundColor = color;
if (color === this.selectedColor) {
colorButton.addClass("is-selected");
}
this.registerDomEvent(colorButton, "click", () => {
this.selectColor(color);
});
});
// Action buttons
const actions = content.createDiv({ cls: "v2-popover-actions" });
const cancelBtn = actions.createEl("button", {
cls: "v2-button v2-button-secondary",
text: "Cancel"
});
this.registerDomEvent(cancelBtn, "click", () => this.close());
const saveBtn = actions.createEl("button", {
cls: "v2-button v2-button-primary",
text: "Create"
});
this.registerDomEvent(saveBtn, "click", () => this.save());
// Handle Enter key on input
this.registerDomEvent(this.nameInput, "keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && this.nameInput?.value.trim()) {
this.save();
}
});
// Focus input
setTimeout(() => this.nameInput?.focus(), 50);
}
private setupPopper() {
if (!this.popoverEl) return;
const popover = this.popoverEl.querySelector(".v2-project-popover") as HTMLElement;
if (!popover) return;
this.popperInstance = createPopper(this.referenceEl, popover, {
placement: "bottom-start",
modifiers: [
{
name: "offset",
options: {
offset: [0, 8],
},
},
{
name: "arrow",
options: {
element: ".v2-popover-arrow",
padding: 8,
},
},
{
name: "preventOverflow",
options: {
boundary: "viewport",
padding: 8,
},
},
{
name: "flip",
options: {
fallbackPlacements: ["top-start", "right", "left"],
},
},
],
});
}
private setupEventHandlers() {
// Click outside to close
this.clickHandler = (e: MouseEvent) => {
if (
this.popoverEl &&
!this.popoverEl.contains(e.target as Node) &&
!this.referenceEl.contains(e.target as Node)
) {
this.close();
}
};
// Delay to avoid immediate close
setTimeout(() => {
if (this.clickHandler) {
document.addEventListener("click", this.clickHandler);
}
}, 0);
// Escape key to close
this.escapeHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
this.close();
}
};
document.addEventListener("keydown", this.escapeHandler);
}
private selectColor(color: string) {
if (!this.popoverEl) return;
this.selectedColor = color;
// Update selected state
this.popoverEl.querySelectorAll(".v2-color-button").forEach(btn => {
btn.removeClass("is-selected");
});
const selectedBtn = this.popoverEl.querySelector(`[data-color="${color}"]`);
selectedBtn?.addClass("is-selected");
}
private save() {
if (!this.nameInput) return;
const name = this.nameInput.value.trim();
if (!name) {
this.nameInput.focus();
this.nameInput.addClass("is-error");
setTimeout(() => this.nameInput?.removeClass("is-error"), 300);
return;
}
const project: CustomProject = {
id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name,
color: this.selectedColor,
createdAt: Date.now(),
updatedAt: Date.now()
};
this.onSave(project);
this.close();
}
private close() {
this.onClose();
this.unload();
}
private cleanup() {
// Clean up event listeners
if (this.clickHandler) {
document.removeEventListener("click", this.clickHandler);
this.clickHandler = null;
}
if (this.escapeHandler) {
document.removeEventListener("keydown", this.escapeHandler);
this.escapeHandler = null;
}
// Clean up Popper instance
if (this.popperInstance) {
this.popperInstance.destroy();
this.popperInstance = null;
}
// Remove DOM elements
if (this.popoverEl) {
this.popoverEl.remove();
this.popoverEl = null;
}
}
}
export class ProjectModal extends Modal {
private plugin: TaskProgressBarPlugin;
private onSave: (project: CustomProject) => void;
private nameInput: HTMLInputElement | null = null;
private selectedColor: string = "#3498db";
private eventListeners: Array<{ element: HTMLElement; event: string; handler: EventListener }> = [];
private readonly colors = [
"#e74c3c",
"#3498db",
"#2ecc71",
"#f39c12",
"#9b59b6",
"#1abc9c",
"#34495e",
"#e67e22",
"#16a085",
"#27ae60",
"#2980b9",
"#8e44ad",
"#2c3e50",
"#f1c40f",
"#d35400"
];
constructor(
app: App,
plugin: TaskProgressBarPlugin,
onSave: (project: CustomProject) => void
) {
super(app);
this.plugin = plugin;
this.onSave = onSave;
}
private addEventListener(element: HTMLElement, event: string, handler: EventListener) {
element.addEventListener(event, handler);
this.eventListeners.push({ element, event, handler });
}
onOpen() {
const { contentEl } = this;
// Add custom class for styling
this.modalEl.addClass("v2-project-modal");
// Title
contentEl.createEl("h2", { text: "Create New Project" });
// Name input section
const nameSection = contentEl.createDiv({ cls: "v2-modal-section" });
nameSection.createEl("label", { text: "Project Name" });
this.nameInput = nameSection.createEl("input", {
cls: "v2-modal-input",
attr: {
type: "text",
placeholder: "Enter project name"
}
});
// Color picker section
const colorSection = contentEl.createDiv({ cls: "v2-modal-section" });
colorSection.createEl("label", { text: "Choose Color" });
const colorGrid = colorSection.createDiv({ cls: "v2-modal-color-grid" });
this.colors.forEach(color => {
const colorButton = colorGrid.createDiv({
cls: "v2-modal-color-button",
attr: { "data-color": color }
});
colorButton.style.backgroundColor = color;
if (color === this.selectedColor) {
colorButton.addClass("is-selected");
}
this.addEventListener(colorButton, "click", () => {
this.selectColor(color);
});
});
// Preview section
const previewSection = contentEl.createDiv({ cls: "v2-modal-section" });
previewSection.createEl("label", { text: "Preview" });
const preview = previewSection.createDiv({ cls: "v2-modal-preview" });
const previewItem = preview.createDiv({ cls: "v2-project-item-preview" });
const previewColor = previewItem.createDiv({ cls: "v2-project-color" });
previewColor.style.backgroundColor = this.selectedColor;
const previewName = previewItem.createSpan({ cls: "v2-project-name" });
// Update preview on name change
this.addEventListener(this.nameInput, "input", () => {
previewName.setText(this.nameInput?.value || "Project Name");
});
previewName.setText("Project Name");
// Footer with action buttons
const footer = contentEl.createDiv({ cls: "v2-modal-footer" });
const cancelBtn = footer.createEl("button", {
cls: "v2-button v2-button-secondary",
text: "Cancel"
});
this.addEventListener(cancelBtn, "click", () => this.close());
const createBtn = footer.createEl("button", {
cls: "v2-button v2-button-primary",
text: "Create Project"
});
this.addEventListener(createBtn, "click", () => this.save());
// Handle Enter key
this.addEventListener(this.nameInput, "keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && this.nameInput?.value.trim()) {
this.save();
}
});
// Focus input
setTimeout(() => this.nameInput?.focus(), 100);
}
onClose() {
// Clean up all event listeners
this.eventListeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
this.eventListeners = [];
// Clear content
const { contentEl } = this;
contentEl.empty();
}
private selectColor(color: string) {
this.selectedColor = color;
// Update selected state
this.modalEl.querySelectorAll(".v2-modal-color-button").forEach(btn => {
btn.removeClass("is-selected");
});
const selectedBtn = this.modalEl.querySelector(`[data-color="${color}"]`);
selectedBtn?.addClass("is-selected");
// Update preview
const previewColor = this.modalEl.querySelector(".v2-project-color") as HTMLElement;
if (previewColor) {
previewColor.style.backgroundColor = color;
}
}
private save() {
if (!this.nameInput) return;
const name = this.nameInput.value.trim();
if (!name) {
this.nameInput.focus();
this.nameInput.addClass("is-error");
setTimeout(() => this.nameInput?.removeClass("is-error"), 300);
return;
}
const project: CustomProject = {
id: `project-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name,
color: this.selectedColor,
createdAt: Date.now(),
updatedAt: Date.now()
};
this.onSave(project);
this.close();
}
}

View file

@ -1,11 +1,11 @@
import { setIcon, Menu } from "obsidian";
import { Component, setIcon, Menu } from "obsidian";
import { WorkspaceSelector } from "./WorkspaceSelector";
import { ProjectList } from "@/experimental/v2/components/ProjectList";
import { Workspace, V2NavigationItem } from "@/experimental/v2/types";
import TaskProgressBarPlugin from "@/index";
import { t } from "@/translations/helper";
export class V2Sidebar {
export class V2Sidebar extends Component {
private containerEl: HTMLElement;
private plugin: TaskProgressBarPlugin;
private workspaceSelector: WorkspaceSelector;
@ -56,11 +56,10 @@ export class V2Sidebar {
private onProjectSelect: (projectId: string) => void,
collapsed: boolean = false
) {
super();
this.containerEl = containerEl;
this.plugin = plugin;
this.collapsed = collapsed;
this.render();
}
private render() {
@ -188,10 +187,16 @@ export class V2Sidebar {
});
projectHeader.createSpan({ text: "Projects" });
const addProjectBtn = projectHeader.createDiv({
cls: "v2-add-project-btn",
const sortProjectBtn = projectHeader.createDiv({
cls: "v2-sort-project-btn",
attr: { "aria-label": "Sort projects" }
});
setIcon(sortProjectBtn, "arrow-up-down");
// Pass sort button to project list for menu handling
sortProjectBtn.addEventListener("click", () => {
(this.projectList as any).showSortMenu?.(sortProjectBtn);
});
setIcon(addProjectBtn, "plus");
const projectListEl = projectsSection.createDiv();
this.projectList = new ProjectList(
@ -199,8 +204,8 @@ export class V2Sidebar {
this.plugin,
this.onProjectSelect
);
// Load projects data
this.projectList.refresh();
// Add ProjectList as a child component
this.addChild(this.projectList);
// Other views section
const otherSection = content.createDiv({ cls: "v2-sidebar-section" });
@ -260,6 +265,15 @@ export class V2Sidebar {
}
}
onload() {
this.render();
}
onunload() {
// Clean up is handled by Component base class
this.containerEl.empty();
}
public setCollapsed(collapsed: boolean) {
this.collapsed = collapsed;
this.render();

View file

@ -134,6 +134,7 @@
flex: 1;
overflow-y: auto;
padding: 8px 0;
--icon-size: 16px;
}
.v2-sidebar-section {
@ -159,13 +160,15 @@
color: var(--text-muted);
}
.v2-add-project-btn {
.v2-add-project-btn,
.v2-sort-project-btn {
cursor: pointer;
color: var(--text-muted);
transition: color 0.15s;
}
.v2-add-project-btn:hover {
.v2-add-project-btn:hover,
.v2-sort-project-btn:hover {
color: var(--text-normal);
}

View file

@ -59,6 +59,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.task-item-container {

View file

@ -0,0 +1,350 @@
/* Project Popover Styles */
.v2-project-popover-container {
position: absolute;
z-index: 1000;
}
.v2-project-popover {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
min-width: 320px;
max-width: 400px;
animation: popover-fade-in 0.2s ease-out;
}
@keyframes popover-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.v2-popover-arrow,
.v2-popover-arrow::before {
position: absolute;
width: 10px;
height: 10px;
background: inherit;
}
.v2-popover-arrow {
visibility: hidden;
}
.v2-popover-arrow::before {
visibility: visible;
content: "";
transform: rotate(45deg);
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
}
/* Arrow positioning for different placements */
.v2-project-popover[data-popper-placement^="top"] .v2-popover-arrow {
bottom: -5px;
}
.v2-project-popover[data-popper-placement^="top"] .v2-popover-arrow::before {
border-top: none;
border-left: none;
}
.v2-project-popover[data-popper-placement^="bottom"] .v2-popover-arrow {
top: -5px;
}
.v2-project-popover[data-popper-placement^="bottom"] .v2-popover-arrow::before {
border-right: none;
border-bottom: none;
}
.v2-project-popover[data-popper-placement^="left"] .v2-popover-arrow {
right: -5px;
}
.v2-project-popover[data-popper-placement^="left"] .v2-popover-arrow::before {
border-bottom: none;
border-left: none;
}
.v2-project-popover[data-popper-placement^="right"] .v2-popover-arrow {
left: -5px;
}
.v2-project-popover[data-popper-placement^="right"] .v2-popover-arrow::before {
border-top: none;
border-right: none;
}
.v2-popover-content {
padding: 16px;
}
.v2-popover-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--background-modifier-border);
}
.v2-popover-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-normal);
}
.v2-popover-section {
margin-bottom: 16px;
}
.v2-popover-section label {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.v2-popover-input {
width: 100%;
padding: 8px 12px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
font-size: 14px;
color: var(--text-normal);
transition: all 0.2s;
}
.v2-popover-input:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
.v2-popover-input.is-error {
border-color: var(--text-error);
animation: shake 0.3s;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.v2-color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.v2-color-button {
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
position: relative;
}
.v2-color-button:hover {
transform: scale(1.1);
}
.v2-color-button.is-selected {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
.v2-color-button.is-selected::after {
content: "✓";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 16px;
font-weight: bold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.v2-popover-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--background-modifier-border);
}
.v2-button {
padding: 6px 16px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
outline: none;
}
.v2-button-primary {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.v2-button-primary:hover {
background: var(--interactive-accent-hover);
}
.v2-button-secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--background-modifier-border);
}
.v2-button-secondary:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
/* Modal Styles using Obsidian native Modal */
.v2-project-modal {
/* Modal will use Obsidian's default styling */
}
.v2-project-modal .modal-content h2 {
margin-bottom: 20px;
font-size: 20px;
font-weight: 600;
color: var(--text-normal);
}
.v2-modal-section {
margin-bottom: 24px;
}
.v2-modal-section label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-muted);
}
.v2-modal-input {
width: 100%;
padding: 10px 14px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
font-size: 16px;
color: var(--text-normal);
transition: all 0.2s;
}
.v2-modal-input:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 3px var(--interactive-accent-hover);
}
.v2-modal-input.is-error {
border-color: var(--text-error);
animation: shake 0.3s;
}
.v2-modal-color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.v2-modal-color-button {
width: 48px;
height: 48px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 3px solid transparent;
position: relative;
}
.v2-modal-color-button:hover {
transform: scale(1.1);
}
.v2-modal-color-button.is-selected {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 3px var(--interactive-accent-hover);
}
.v2-modal-color-button.is-selected::after {
content: "✓";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 20px;
font-weight: bold;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.v2-modal-preview {
padding: 12px;
background: var(--background-secondary);
border-radius: 8px;
}
.v2-project-item-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background: var(--background-primary);
border-radius: 6px;
}
.v2-project-item-preview .v2-project-color {
width: 8px;
height: 32px;
border-radius: 2px;
flex-shrink: 0;
}
.v2-project-item-preview .v2-project-name {
font-size: 14px;
font-weight: 500;
color: var(--text-normal);
}
.v2-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px;
border-top: 1px solid var(--background-modifier-border);
}
.v2-modal-footer .v2-button {
padding: 10px 24px;
font-size: 15px;
}

View file

@ -414,7 +414,8 @@
display: none;
}
.task-genius-view .task-empty-state {
.task-genius-view .task-empty-state,
.task-genius-v2-view .task-empty-state {
width: 100%;
height: 100%;
flex: 1;

File diff suppressed because one or more lines are too long