feat(fluent): add mobile drawer navigation and responsive design

- Implement mobile drawer with overlay and swipe gestures
- Add touch gesture support for drawer open/close
- Refactor sidebar rail mode for better collapsed state
- Add responsive breakpoints for mobile devices
- Optimize search and navigation UI for small screens
- Add mobile-specific CSS classes and styling
- Improve task details panel layout on mobile
This commit is contained in:
Quorafind 2025-09-24 08:42:03 +08:00
parent 59643c038f
commit 7721ac191c
8 changed files with 1144 additions and 310 deletions

View file

@ -9,6 +9,7 @@ import {
App,
Menu,
debounce,
Platform,
} from "obsidian";
import { Task } from "@/types/task";
import TaskProgressBarPlugin from "@/index";
@ -17,11 +18,21 @@ import "@/styles/task-details.css";
import { t } from "@/translations/helper";
import { clearAllMarks } from "@/components/ui/renderers/MarkdownRenderer";
import { StatusComponent } from "@/components/ui/feedback/StatusIndicator";
import { ContextSuggest, ProjectSuggest, TagSuggest } from "@/components/ui/inputs/AutoComplete";
import {
ContextSuggest,
ProjectSuggest,
TagSuggest,
} from "@/components/ui/inputs/AutoComplete";
import { FileTask } from "@/types/file-task";
import { getEffectiveProject, isProjectReadonly } from "@/utils/task/task-operations";
import {
getEffectiveProject,
isProjectReadonly,
} from "@/utils/task/task-operations";
import { OnCompletionConfigurator } from "@/components/features/on-completion/OnCompletionConfigurator";
import { timestampToLocalDateString, localDateStringToTimestamp } from "@/utils/date/date-display-helper";
import {
timestampToLocalDateString,
localDateStringToTimestamp,
} from "@/utils/date/date-display-helper";
function getStatus(task: Task, settings: TaskProgressBarSettings) {
const status = Object.keys(settings.taskStatuses).find((key) => {
@ -60,16 +71,16 @@ function mapTextStatusToSymbol(status: string): string {
if (!status) return " ";
if (status.length === 1) return status; // already a symbol mark
const map: Record<string, string> = {
"completed": "x",
"done": "x",
"finished": "x",
completed: "x",
done: "x",
finished: "x",
"in-progress": "/",
"in progress": "/",
"doing": "/",
"planned": "?",
"todo": "?",
"cancelled": "-",
"canceled": "-",
doing: "/",
planned: "?",
todo: "?",
cancelled: "-",
canceled: "-",
"not-started": " ",
"not started": " ",
};
@ -155,18 +166,24 @@ export class TaskDetailsComponent extends Component {
const headerEl = this.containerEl.createDiv({ cls: "details-header" });
headerEl.setText(t("Task Details"));
headerEl.createEl(
"div",
{
cls: "details-close-btn",
},
(el) => {
new ExtraButtonComponent(el).setIcon("x").onClick(() => {
this.toggleDetailsVisibility &&
this.toggleDetailsVisibility(false);
});
}
);
// Only show close button on mobile or if explicitly requested
if (
Platform.isPhone ||
this.containerEl.parentElement?.hasClass("tg-v2-container")
) {
headerEl.createEl(
"div",
{
cls: "details-close-btn",
},
(el) => {
new ExtraButtonComponent(el).setIcon("x").onClick(() => {
this.toggleDetailsVisibility &&
this.toggleDetailsVisibility(false);
});
}
);
}
// Create content container
this.contentEl = this.containerEl.createDiv({ cls: "details-content" });
@ -409,10 +426,12 @@ export class TaskDetailsComponent extends Component {
console.log("tagsInput", tagsInput, task.metadata.tags);
// Remove # prefix from tags when displaying them
tagsInput.setValue(
task.metadata.tags
task.metadata.tags
? task.metadata.tags
.map(tag => tag.startsWith("#") ? tag.slice(1) : tag)
.join(", ")
.map((tag) =>
tag.startsWith("#") ? tag.slice(1) : tag
)
.join(", ")
: ""
);
tagsField
@ -462,8 +481,10 @@ export class TaskDetailsComponent extends Component {
});
if (task.metadata.dueDate) {
// Use helper to correctly display UTC noon timestamp as local date
dueDateInput.value = timestampToLocalDateString(task.metadata.dueDate);
} // Start date
dueDateInput.value = timestampToLocalDateString(
task.metadata.dueDate
);
} // Start date
const startDateField = this.createFormField(
this.editFormEl,
t("Start Date")
@ -474,7 +495,9 @@ export class TaskDetailsComponent extends Component {
});
if (task.metadata.startDate) {
// Use helper to correctly display UTC noon timestamp as local date
startDateInput.value = timestampToLocalDateString(task.metadata.startDate);
startDateInput.value = timestampToLocalDateString(
task.metadata.startDate
);
}
// Scheduled date
@ -488,7 +511,9 @@ export class TaskDetailsComponent extends Component {
});
if (task.metadata.scheduledDate) {
// Use helper to correctly display UTC noon timestamp as local date
scheduledDateInput.value = timestampToLocalDateString(task.metadata.scheduledDate);
scheduledDateInput.value = timestampToLocalDateString(
task.metadata.scheduledDate
);
}
// Cancelled date
@ -502,7 +527,9 @@ export class TaskDetailsComponent extends Component {
});
if (task.metadata.cancelledDate) {
// Use helper to correctly display UTC noon timestamp as local date
cancelledDateInput.value = timestampToLocalDateString(task.metadata.cancelledDate);
cancelledDateInput.value = timestampToLocalDateString(
task.metadata.cancelledDate
);
}
// On completion action
@ -538,7 +565,9 @@ export class TaskDetailsComponent extends Component {
? tagsValue
.split(",")
.map((tag) => tag.trim())
.map((tag) => tag.startsWith("#") ? tag.slice(1) : tag) // Remove # prefix if present
.map((tag) =>
tag.startsWith("#") ? tag.slice(1) : tag
) // Remove # prefix if present
.filter((tag) => tag)
: [];
@ -592,7 +621,8 @@ export class TaskDetailsComponent extends Component {
const scheduledDateValue = scheduledDateInput.value;
if (scheduledDateValue) {
// Use helper to convert local date string to UTC noon timestamp
const newScheduledDate = localDateStringToTimestamp(scheduledDateValue);
const newScheduledDate =
localDateStringToTimestamp(scheduledDateValue);
// Only update if the date has changed or is different from the original
if (task.metadata.scheduledDate !== newScheduledDate) {
metadata.scheduledDate = newScheduledDate;
@ -610,7 +640,8 @@ export class TaskDetailsComponent extends Component {
const cancelledDateValue = cancelledDateInput.value;
if (cancelledDateValue) {
// Use helper to convert local date string to UTC noon timestamp
const newCancelledDate = localDateStringToTimestamp(cancelledDateValue);
const newCancelledDate =
localDateStringToTimestamp(cancelledDateValue);
// Only update if the date has changed or is different from the original
if (task.metadata.cancelledDate !== newCancelledDate) {
metadata.cancelledDate = newCancelledDate;

View file

@ -133,6 +133,15 @@ export class TaskViewV2 extends ItemView {
// Sidebar collapse state
private isSidebarCollapsed: boolean = false;
private sidebarToggleBtn: HTMLElement | null = null;
private isMobileDrawerOpen: boolean = false;
private drawerOverlay: HTMLElement | null = null;
// Touch gesture tracking
private touchStartX: number = 0;
private touchStartY: number = 0;
private touchCurrentX: number = 0;
private isSwiping: boolean = false;
private swipeThreshold: number = 50;
// V2 Details panel
private detailsPanelEl: HTMLElement | null = null;
@ -209,7 +218,7 @@ export class TaskViewV2 extends ItemView {
await this.applyWorkspaceSettings();
this.restoreFilterStateFromWorkspace();
await this.loadTasks();
this.scheduleUpdate('workspace-switch');
this.scheduleUpdate("workspace-switch");
}
})
);
@ -219,7 +228,7 @@ export class TaskViewV2 extends ItemView {
if (payload.workspaceId === this.workspaceId) {
await this.applyWorkspaceSettings();
await this.loadTasks();
this.scheduleUpdate('workspace-overrides');
this.scheduleUpdate("workspace-overrides");
}
})
);
@ -274,6 +283,11 @@ export class TaskViewV2 extends ItemView {
cls: "tg-v2-container",
});
// Add mobile class to container for proper styling
if (Platform.isPhone) {
this.rootContainerEl.addClass("is-mobile");
}
// Create layout structure
const layoutContainer = this.rootContainerEl.createDiv({
cls: "tg-v2-layout",
@ -284,6 +298,22 @@ export class TaskViewV2 extends ItemView {
cls: "tg-v2-sidebar-container",
});
// Add mobile-specific classes and overlay
if (Platform.isPhone) {
sidebarEl.addClass("is-mobile-drawer");
// Create overlay for mobile drawer
this.drawerOverlay = layoutContainer.createDiv({
cls: "drawer-overlay",
});
this.drawerOverlay.style.display = "none";
this.drawerOverlay.addEventListener("click", () => {
this.closeMobileDrawer();
});
// Add swipe gesture support
this.initializeMobileSwipeGestures();
}
// Main content area
const mainContainer = layoutContainer.createDiv({
cls: "tg-v2-main-container",
@ -367,16 +397,24 @@ export class TaskViewV2 extends ItemView {
);
// Single initial render
console.log("[TG-V2] Performing initial render with:", this.currentViewId);
console.log(
"[TG-V2] Performing initial render with:",
this.currentViewId
);
// Keep isInitializing true during first switchView
this.switchView(this.currentViewId);
// Refresh top navigation (badge) after tasks are loaded
this.topNavigation?.refresh();
// Sidebar toggle in header and responsive collapse
// Sidebar toggle in header (desktop) and responsive collapse
this.createSidebarToggle();
this.checkAndCollapseSidebar();
// Only check and collapse on desktop
if (!Platform.isPhone) {
this.checkAndCollapseSidebar();
}
this.createTaskMark();
// Create action buttons in Obsidian view header
console.log("[TG-V2] Creating action buttons");
@ -388,12 +426,29 @@ export class TaskViewV2 extends ItemView {
}
private initializeSidebar(containerEl: HTMLElement) {
// On mobile, start with sidebar completely hidden (drawer closed)
const initialCollapsedState = Platform.isPhone
? true
: this.isSidebarCollapsed;
this.sidebar = new V2Sidebar(
containerEl,
this.plugin,
(viewId) => this.handleNavigate(viewId),
(projectId) => this.handleProjectSelect(projectId),
this.isSidebarCollapsed
(viewId) => {
this.handleNavigate(viewId);
// Auto-close drawer on mobile after navigation
if (Platform.isPhone) {
this.closeMobileDrawer();
}
},
(projectId) => {
this.handleProjectSelect(projectId);
// Auto-close drawer on mobile after selection
if (Platform.isPhone) {
this.closeMobileDrawer();
}
},
initialCollapsedState
);
// Add sidebar as a child component for proper lifecycle management
this.addChild(this.sidebar);
@ -413,7 +468,8 @@ export class TaskViewV2 extends ItemView {
() => {}, // Filter is now in Obsidian view header
() => {}, // Sort is now in Obsidian view header
() => this.handleSettingsClick(),
availableModes
availableModes,
() => this.toggleSidebar() // Pass sidebar toggle callback
);
}
@ -604,9 +660,11 @@ export class TaskViewV2 extends ItemView {
}
private createSidebarToggle() {
const headerBtns = this.headerEl?.find(
".view-header-nav-buttons"
) as HTMLElement | null;
const headerBtns = !Platform.isPhone
? (this.headerEl?.find(
".view-header-nav-buttons"
) as HTMLElement | null)
: (this.headerEl?.find(".view-header-left") as HTMLElement);
if (!headerBtns) {
console.warn("[TG-V2] header buttons container not found");
return;
@ -618,27 +676,148 @@ export class TaskViewV2 extends ItemView {
cls: "panel-toggle-btn",
});
const btn = new ButtonComponent(this.sidebarToggleBtn);
btn.setIcon("panel-left-dashed")
btn.setIcon(Platform.isPhone ? "menu" : "panel-left-dashed")
.setTooltip(t("Toggle Sidebar"))
.setClass("clickable-icon")
.onClick(() => this.toggleSidebar());
}
private toggleSidebar() {
this.isSidebarCollapsed = !this.isSidebarCollapsed;
this.sidebar?.setCollapsed(this.isSidebarCollapsed);
this.rootContainerEl?.toggleClass(
"v2-sidebar-collapsed",
this.isSidebarCollapsed
private createTaskMark() {
this.titleEl.setText(
t("{{num}} Tasks", {
interpolation: {
num: this.tasks.length,
},
})
);
}
private toggleSidebar() {
if (Platform.isPhone) {
// On mobile, toggle the drawer open/closed
if (this.isMobileDrawerOpen) {
this.closeMobileDrawer();
} else {
this.openMobileDrawer();
}
} else {
// On desktop, toggle collapse state
this.isSidebarCollapsed = !this.isSidebarCollapsed;
this.sidebar?.setCollapsed(this.isSidebarCollapsed);
this.rootContainerEl?.toggleClass(
"v2-sidebar-collapsed",
this.isSidebarCollapsed
);
}
}
private openMobileDrawer() {
this.isMobileDrawerOpen = true;
this.rootContainerEl?.addClass("drawer-open");
if (this.drawerOverlay) {
this.drawerOverlay.style.display = "block";
}
// Show the sidebar
this.sidebar?.setCollapsed(false);
}
private closeMobileDrawer() {
this.isMobileDrawerOpen = false;
this.rootContainerEl?.removeClass("drawer-open");
if (this.drawerOverlay) {
this.drawerOverlay.style.display = "none";
}
// Hide the sidebar
this.sidebar?.setCollapsed(true);
}
private initializeMobileSwipeGestures() {
if (!Platform.isPhone) return;
// Edge swipe to open drawer
this.registerDomEvent(document, "touchstart", (e: TouchEvent) => {
if (this.isMobileDrawerOpen) {
// Track for swipe-to-close when drawer is open
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.isSwiping = true;
} else {
// Check if touch started from left edge
const touch = e.touches[0];
if (touch.clientX < 20) {
// 20px edge detection zone
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.isSwiping = true;
}
}
});
this.registerDomEvent(document, "touchmove", (e: TouchEvent) => {
if (!this.isSwiping) return;
const touch = e.touches[0];
this.touchCurrentX = touch.clientX;
const deltaX = this.touchCurrentX - this.touchStartX;
const deltaY = Math.abs(touch.clientY - this.touchStartY);
// Check if horizontal swipe (not vertical scroll)
if (deltaY > 50) {
this.isSwiping = false;
return;
}
if (!this.isMobileDrawerOpen && deltaX > this.swipeThreshold) {
// Swipe right from edge - open drawer
this.openMobileDrawer();
this.isSwiping = false;
} else if (
this.isMobileDrawerOpen &&
deltaX < -this.swipeThreshold
) {
// Swipe left when drawer is open - close it
const sidebarEl = this.rootContainerEl?.querySelector(
".tg-v2-sidebar-container"
);
if (sidebarEl) {
const sidebarRect = sidebarEl.getBoundingClientRect();
// Only close if swipe started on the sidebar
if (this.touchStartX < sidebarRect.right) {
this.closeMobileDrawer();
this.isSwiping = false;
}
}
}
});
this.registerDomEvent(document, "touchend", () => {
this.isSwiping = false;
this.touchStartX = 0;
this.touchCurrentX = 0;
});
this.registerDomEvent(document, "touchcancel", () => {
this.isSwiping = false;
this.touchStartX = 0;
this.touchCurrentX = 0;
});
}
onResize(): void {
this.checkAndCollapseSidebar();
// Only check and collapse on desktop
if (!Platform.isPhone) {
this.checkAndCollapseSidebar();
}
}
private checkAndCollapseSidebar() {
// Auto-collapse on narrow panes
// Skip auto-collapse on mobile, as we use drawer mode
if (Platform.isPhone) {
return;
}
// Auto-collapse on narrow panes (desktop only)
try {
const width = (this.leaf as any)?.width ?? 0;
if (width > 0 && width < 768) {
@ -656,18 +835,25 @@ export class TaskViewV2 extends ItemView {
if (this.isInitializing && !forceHideAll) {
// Skip smart hiding during initialization unless forced
console.log("[TG-V2] Skipping hideAllComponents during initialization (not initial hide)");
console.log(
"[TG-V2] Skipping hideAllComponents during initialization (not initial hide)"
);
return;
}
// Smart hiding - only hide currently visible component (unless initial hide)
if (!isInitialHide && this.currentVisibleComponent) {
console.log("[TG-V2] Smart hide - only hiding current visible component");
console.log(
"[TG-V2] Smart hide - only hiding current visible component"
);
this.currentVisibleComponent.containerEl?.hide();
this.currentVisibleComponent = null;
} else {
// Hide all components (during initial setup or when no current visible tracked)
console.log("[TG-V2] Hiding all components", isInitialHide ? "(initial hide)" : "");
console.log(
"[TG-V2] Hiding all components",
isInitialHide ? "(initial hide)" : ""
);
this.contentComponent?.containerEl.hide();
this.forecastComponent?.containerEl.hide();
this.tagsComponent?.containerEl.hide();
@ -696,7 +882,7 @@ export class TaskViewV2 extends ItemView {
console.log("[TG-V2] debouncedViewUpdate triggered");
if (!this.isInitializing) {
await this.loadTasks(false); // Don't show loading state for updates
this.scheduleUpdate('dataflow');
this.scheduleUpdate("dataflow");
// Refresh top navigation (badge)
this.topNavigation?.refresh();
// Also refresh project list when tasks update
@ -707,7 +893,7 @@ export class TaskViewV2 extends ItemView {
// Add debounced filter application
const debouncedApplyFilter = debounce(() => {
if (!this.isInitializing) {
this.scheduleUpdate('filter-changed');
this.scheduleUpdate("filter-changed");
}
}, 400);
@ -808,7 +994,11 @@ export class TaskViewV2 extends ItemView {
try {
console.log("[TG-V2] loadTasks started, showLoading:", showLoading);
// Only show loading state if requested, not initializing, and we don't have tasks
if (showLoading && !this.isInitializing && (!this.tasks || this.tasks.length === 0)) {
if (
showLoading &&
!this.isInitializing &&
(!this.tasks || this.tasks.length === 0)
) {
console.log("[TG-V2] Setting isLoading to true");
this.isLoading = true;
this.loadError = null;
@ -1033,8 +1223,15 @@ export class TaskViewV2 extends ItemView {
);
// Skip if we're switching to the same view (but never skip during initialization)
if (!this.isInitializing && this.currentViewId === viewId && !project && this.filteredTasks.length > 0) {
console.log("[TG-V2] Already on this view with data, skipping switchView");
if (
!this.isInitializing &&
this.currentViewId === viewId &&
!project &&
this.filteredTasks.length > 0
) {
console.log(
"[TG-V2] Already on this view with data, skipping switchView"
);
return;
}
@ -1242,7 +1439,10 @@ export class TaskViewV2 extends ItemView {
}
// Apply filters only if needed (not during initialization as they're already applied)
if (!this.isInitializing && (this.filteredTasks.length === 0 || project)) {
if (
!this.isInitializing &&
(this.filteredTasks.length === 0 || project)
) {
console.log("[TG-V2] Applying filters in switchView");
this.applyFilters();
console.log(
@ -1264,7 +1464,10 @@ export class TaskViewV2 extends ItemView {
`[TG-V2] Calling setTasks for ${viewId} with ALL tasks (unfiltered):`,
this.tasks.length,
"tasks, first few:",
this.tasks.slice(0, 3).map(t => ({ id: t.id, project: t.metadata?.project }))
this.tasks.slice(0, 3).map((t) => ({
id: t.id,
project: t.metadata?.project,
}))
);
// ReviewComponent and TagsComponent need all tasks to build their lists
// and only accept a single parameter
@ -1654,7 +1857,7 @@ export class TaskViewV2 extends ItemView {
private updateView() {
// Schedule an update instead of immediately updating
if (!this.isInitializing) {
this.scheduleUpdate('updateView');
this.scheduleUpdate("updateView");
}
}
@ -1939,14 +2142,17 @@ export class TaskViewV2 extends ItemView {
if (viewId === "new-task") {
new QuickCaptureModal(this.app, this.plugin).open();
} else {
console.log(`[TG-V2] handleNavigate to ${viewId}, current tasks:`, this.tasks.length);
console.log(
`[TG-V2] handleNavigate to ${viewId}, current tasks:`,
this.tasks.length
);
this.switchView(viewId);
}
}
private handleSearch(query: string) {
this.viewState.searchQuery = query;
this.scheduleUpdate('search');
this.scheduleUpdate("search");
// Persist and update header UI
this.saveFilterStateToWorkspace();
this.updateActionButtons();
@ -2078,6 +2284,40 @@ export class TaskViewV2 extends ItemView {
visible ? t("Hide Details") : t("Show Details")
);
}
// On mobile, add click handler to overlay to close details
if (Platform.isPhone && visible) {
// Use setTimeout to avoid immediate close on open
setTimeout(() => {
const overlayClickHandler = (e: MouseEvent) => {
// Check if click is on the overlay (pseudo-element area)
const detailsEl =
this.rootContainerEl?.querySelector(".task-details");
if (detailsEl && !detailsEl.contains(e.target as Node)) {
this.toggleDetailsVisibility(false);
document.removeEventListener(
"click",
overlayClickHandler
);
}
};
// Add the event listener
document.addEventListener("click", overlayClickHandler);
// Store the handler to remove it later
(this as any).mobileDetailsOverlayHandler = overlayClickHandler;
}, 100);
} else if (
Platform.isPhone &&
!visible &&
(this as any).mobileDetailsOverlayHandler
) {
// Remove the event listener when closing
document.removeEventListener(
"click",
(this as any).mobileDetailsOverlayHandler
);
delete (this as any).mobileDetailsOverlayHandler;
}
}
private resetCurrentFilter() {
@ -2552,10 +2792,12 @@ export class TaskViewV2 extends ItemView {
);
// Check what kind of updates are pending
const needsFullReload = this.pendingUpdates.has('dataflow') ||
this.pendingUpdates.has('workspace-switch');
const needsFilterRefresh = this.pendingUpdates.has('filter-changed') ||
this.pendingUpdates.has('search');
const needsFullReload =
this.pendingUpdates.has("dataflow") ||
this.pendingUpdates.has("workspace-switch");
const needsFilterRefresh =
this.pendingUpdates.has("filter-changed") ||
this.pendingUpdates.has("search");
this.pendingUpdates.clear();
this.updateScheduled = false;
@ -2807,6 +3049,15 @@ export class TaskViewV2 extends ItemView {
this.saveWorkspaceLayout();
this.saveFilterStateToWorkspace();
// Clean up mobile event listeners
if (Platform.isPhone && (this as any).mobileDetailsOverlayHandler) {
document.removeEventListener(
"click",
(this as any).mobileDetailsOverlayHandler
);
delete (this as any).mobileDetailsOverlayHandler;
}
// Clean up components
if (this.kanbanComponent) {
this.kanbanComponent.unload();

View file

@ -1,7 +1,11 @@
import { Component, Platform, setIcon, Menu, Modal, App } from "obsidian";
import TaskProgressBarPlugin from "../../../index";
import { Task } from "../../../types/task";
import { ProjectPopover, ProjectModal, EditProjectModal } from "./ProjectPopover";
import {
ProjectPopover,
ProjectModal,
EditProjectModal,
} from "./ProjectPopover";
import type { CustomProject } from "../../../common/setting-definition";
import { t } from "@/translations/helper";
@ -102,7 +106,7 @@ export class ProjectList extends Component {
if (projectName) {
if (!projectMap.has(projectName)) {
// Convert dashes back to spaces for display
const displayName = projectName.replace(/-/g, ' ');
const displayName = projectName.replace(/-/g, " ");
projectMap.set(projectName, {
id: projectName,
name: projectName,
@ -182,9 +186,15 @@ export class ProjectList extends Component {
this.projects.sort((a, b) => {
switch (this.currentSort) {
case "name-asc":
return this.collator.compare(a.displayName || a.name, b.displayName || b.name);
return this.collator.compare(
a.displayName || a.name,
b.displayName || b.name
);
case "name-desc":
return this.collator.compare(b.displayName || b.name, a.displayName || a.name);
return this.collator.compare(
b.displayName || b.name,
a.displayName || a.name
);
case "tasks-asc":
return a.taskCount - b.taskCount;
case "tasks-desc":
@ -205,7 +215,7 @@ export class ProjectList extends Component {
const separator = this.plugin.settings.projectPathSeparator || "/";
// Process each project and create intermediate nodes as needed
this.projects.forEach(project => {
this.projects.forEach((project) => {
const segments = this.parseProjectPath(project.name);
if (segments.length === 0) return;
@ -218,21 +228,27 @@ export class ProjectList extends Component {
const isLeaf = i === segments.length - 1;
// Build the full path up to this segment
currentPath = currentPath ? `${currentPath}${separator}${segment}` : segment;
currentPath = currentPath
? `${currentPath}${separator}${segment}`
: segment;
// Check if node already exists
let node = nodeMap.get(currentPath);
if (!node) {
// Create node - use actual project for leaf, virtual for intermediate
const nodeProject = isLeaf ? project : {
id: currentPath,
name: currentPath,
displayName: segment,
color: this.generateColorForProject(currentPath),
taskCount: 0,
isVirtual: true
};
const nodeProject = isLeaf
? project
: {
id: currentPath,
name: currentPath,
displayName: segment,
color: this.generateColorForProject(
currentPath
),
taskCount: 0,
isVirtual: true,
};
node = {
project: nodeProject,
@ -241,7 +257,7 @@ export class ProjectList extends Component {
expanded: this.expandedNodes.has(currentPath),
path: segments.slice(0, i + 1),
fullPath: currentPath,
parent: parentNode
parent: parentNode,
};
nodeMap.set(currentPath, node);
@ -280,8 +296,19 @@ export class ProjectList extends Component {
// Normalize the path by trimming and removing duplicate separators
const normalized = projectName
.trim()
.replace(new RegExp(`${this.escapeRegExp(separator)}+`, 'g'), separator)
.replace(new RegExp(`^${this.escapeRegExp(separator)}|${this.escapeRegExp(separator)}$`, 'g'), '');
.replace(
new RegExp(`${this.escapeRegExp(separator)}+`, "g"),
separator
)
.replace(
new RegExp(
`^${this.escapeRegExp(separator)}|${this.escapeRegExp(
separator
)}$`,
"g"
),
""
);
if (!normalized) {
return [];
@ -291,11 +318,11 @@ export class ProjectList extends Component {
}
private escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
private sortTreeNodes(nodes: ProjectTreeNode[]) {
nodes.forEach(node => {
nodes.forEach((node) => {
if (node.children.length > 0) {
this.sortTreeNodes(node.children);
}
@ -318,9 +345,13 @@ export class ProjectList extends Component {
case "tasks-desc":
return b.project.taskCount - a.project.taskCount;
case "created-asc":
return (a.project.createdAt || 0) - (b.project.createdAt || 0);
return (
(a.project.createdAt || 0) - (b.project.createdAt || 0)
);
case "created-desc":
return (b.project.createdAt || 0) - (a.project.createdAt || 0);
return (
(b.project.createdAt || 0) - (a.project.createdAt || 0)
);
default:
return 0;
}
@ -328,7 +359,7 @@ export class ProjectList extends Component {
}
private updateParentTaskCounts(nodes: ProjectTreeNode[]) {
nodes.forEach(node => {
nodes.forEach((node) => {
if (node.children.length > 0) {
this.updateParentTaskCounts(node.children);
// Sum up child task counts
@ -341,7 +372,8 @@ export class ProjectList extends Component {
if (node.project.isVirtual) {
node.project.taskCount = childTotal;
} else {
node.project.taskCount = node.project.taskCount + childTotal;
node.project.taskCount =
node.project.taskCount + childTotal;
}
}
});
@ -420,10 +452,20 @@ export class ProjectList extends Component {
});
}
private renderTreeNodes(container: HTMLElement, nodes: ProjectTreeNode[], level: number) {
nodes.forEach(node => {
private renderTreeNodes(
container: HTMLElement,
nodes: ProjectTreeNode[],
level: number
) {
nodes.forEach((node) => {
const hasChildren = node.children.length > 0;
this.renderProjectItem(container, node.project, level, hasChildren, node);
this.renderProjectItem(
container,
node.project,
level,
hasChildren,
node
);
if (hasChildren && node.expanded) {
this.renderTreeNodes(container, node.children, level + 1);
@ -442,7 +484,7 @@ export class ProjectList extends Component {
cls: "v2-project-item",
attr: {
"data-project-id": project.id,
"data-level": String(level)
"data-level": String(level),
},
});
@ -492,16 +534,24 @@ export class ProjectList extends Component {
displayText = project.displayName || project.name;
} else {
// For real projects, extract the last segment
const separator = this.plugin.settings.projectPathSeparator || "/";
const separator =
this.plugin.settings.projectPathSeparator || "/";
const nameToSplit = project.name;
const segments = nameToSplit.split(separator);
const lastSegment = segments[segments.length - 1] || project.name;
const lastSegment =
segments[segments.length - 1] || project.name;
// If project has a custom displayName, try to preserve it
// but still show only the relevant part for the tree level
if (project.displayName && project.displayName !== project.name) {
const displaySegments = project.displayName.split(separator);
displayText = displaySegments[displaySegments.length - 1] || lastSegment;
if (
project.displayName &&
project.displayName !== project.name
) {
const displaySegments =
project.displayName.split(separator);
displayText =
displaySegments[displaySegments.length - 1] ||
lastSegment;
} else {
displayText = lastSegment;
}
@ -523,7 +573,7 @@ export class ProjectList extends Component {
this.registerDomEvent(projectItem, "click", (e: MouseEvent) => {
// Don't trigger if clicking on chevron
if (!(e.target as HTMLElement).closest('.v2-project-chevron')) {
if (!(e.target as HTMLElement).closest(".v2-project-chevron")) {
// Virtual nodes select all their children
if (project.isVirtual && treeNode) {
this.selectVirtualNode(treeNode);
@ -536,10 +586,14 @@ export class ProjectList extends Component {
// Add context menu handler (only for non-virtual projects)
if (!project.isVirtual) {
this.registerDomEvent(projectItem, "contextmenu", (e: MouseEvent) => {
e.preventDefault();
this.showProjectContextMenu(e, project);
});
this.registerDomEvent(
projectItem,
"contextmenu",
(e: MouseEvent) => {
e.preventDefault();
this.showProjectContextMenu(e, project);
}
);
}
}
@ -550,7 +604,7 @@ export class ProjectList extends Component {
if (!n.project.isVirtual) {
projectIds.push(n.project.id);
}
n.children.forEach(child => collectProjects(child));
n.children.forEach((child) => collectProjects(child));
};
collectProjects(node);
@ -593,7 +647,7 @@ export class ProjectList extends Component {
this.currentPopover = null;
}
if (Platform.isMobile) {
if (Platform.isPhone) {
// Mobile: Use Obsidian Modal
const modal = new ProjectModal(
this.plugin.app,
@ -681,7 +735,8 @@ export class ProjectList extends Component {
this.projects.push({
id: customProject.id,
name: customProject.name,
displayName: customProject.displayName || customProject.name,
displayName:
customProject.displayName || customProject.name,
color: customProject.color,
taskCount: 0, // Will be updated by task counting
createdAt: customProject.createdAt,
@ -691,7 +746,8 @@ export class ProjectList extends Component {
// Update existing project with custom color
this.projects[existingIndex].id = customProject.id;
this.projects[existingIndex].color = customProject.color;
this.projects[existingIndex].displayName = customProject.displayName || customProject.name;
this.projects[existingIndex].displayName =
customProject.displayName || customProject.name;
this.projects[existingIndex].createdAt =
customProject.createdAt;
this.projects[existingIndex].updatedAt =
@ -709,7 +765,11 @@ export class ProjectList extends Component {
icon: string;
}[] = [
{ label: t("Name (A-Z)"), value: "name-asc", icon: "arrow-up-a-z" },
{ label: t("Name (Z-A)"), value: "name-desc", icon: "arrow-down-a-z" },
{
label: t("Name (Z-A)"),
value: "name-desc",
icon: "arrow-down-a-z",
},
{
label: t("Tasks (Low to High)"),
value: "tasks-asc",
@ -763,15 +823,14 @@ export class ProjectList extends Component {
const menu = new Menu();
// Check if this is a custom project
const isCustomProject = this.plugin.settings.projectConfig?.customProjects?.some(
cp => cp.id === project.id || cp.name === project.name
);
const isCustomProject =
this.plugin.settings.projectConfig?.customProjects?.some(
(cp) => cp.id === project.id || cp.name === project.name
);
// Edit Project option
menu.addItem((item) => {
item
.setTitle(t("Edit Project"))
.setIcon("edit");
item.setTitle(t("Edit Project")).setIcon("edit");
if (isCustomProject) {
item.onClick(() => {
@ -784,9 +843,7 @@ export class ProjectList extends Component {
// Delete Project option
menu.addItem((item) => {
item
.setTitle(t("Delete Project"))
.setIcon("trash");
item.setTitle(t("Delete Project")).setIcon("trash");
if (isCustomProject) {
item.onClick(() => {
@ -802,9 +859,10 @@ export class ProjectList extends Component {
private editProject(project: Project) {
// Find the custom project data
let customProject = this.plugin.settings.projectConfig?.customProjects?.find(
cp => cp.id === project.id || cp.name === project.name
);
let customProject =
this.plugin.settings.projectConfig?.customProjects?.find(
(cp) => cp.id === project.id || cp.name === project.name
);
if (!customProject) {
// Create a new custom project entry if it doesn't exist
@ -814,7 +872,7 @@ export class ProjectList extends Component {
displayName: project.displayName || project.name,
color: project.color,
createdAt: Date.now(),
updatedAt: Date.now()
updatedAt: Date.now(),
};
}
@ -860,14 +918,18 @@ export class ProjectList extends Component {
}
// Find and update the project
const index = this.plugin.settings.projectConfig.customProjects.findIndex(
cp => cp.id === updatedProject.id
);
const index =
this.plugin.settings.projectConfig.customProjects.findIndex(
(cp) => cp.id === updatedProject.id
);
if (index !== -1) {
this.plugin.settings.projectConfig.customProjects[index] = updatedProject;
this.plugin.settings.projectConfig.customProjects[index] =
updatedProject;
} else {
this.plugin.settings.projectConfig.customProjects.push(updatedProject);
this.plugin.settings.projectConfig.customProjects.push(
updatedProject
);
}
// Save settings
@ -891,23 +953,29 @@ export class ProjectList extends Component {
const { contentEl } = this;
contentEl.createEl("h2", { text: t("Delete Project") });
contentEl.createEl("p", {
text: t(`Are you sure you want to delete "${project.displayName || project.name}"?`)
text: t(
`Are you sure you want to delete "${
project.displayName || project.name
}"?`
),
});
contentEl.createEl("p", {
cls: "mod-warning",
text: t("This action cannot be undone.")
text: t("This action cannot be undone."),
});
const buttonContainer = contentEl.createDiv({ cls: "modal-button-container" });
const buttonContainer = contentEl.createDiv({
cls: "modal-button-container",
});
const cancelBtn = buttonContainer.createEl("button", {
text: t("Cancel")
text: t("Cancel"),
});
cancelBtn.addEventListener("click", () => this.close());
const confirmBtn = buttonContainer.createEl("button", {
text: t("Delete"),
cls: "mod-warning"
cls: "mod-warning",
});
confirmBtn.addEventListener("click", () => {
this.onConfirm();
@ -922,12 +990,16 @@ export class ProjectList extends Component {
})(this.plugin.app, async () => {
// Remove from custom projects
if (this.plugin.settings.projectConfig?.customProjects) {
const index = this.plugin.settings.projectConfig.customProjects.findIndex(
cp => cp.id === project.id || cp.name === project.name
);
const index =
this.plugin.settings.projectConfig.customProjects.findIndex(
(cp) => cp.id === project.id || cp.name === project.name
);
if (index !== -1) {
this.plugin.settings.projectConfig.customProjects.splice(index, 1);
this.plugin.settings.projectConfig.customProjects.splice(
index,
1
);
await this.plugin.saveSettings();
// If this was the active project, clear selection

View file

@ -1,4 +1,4 @@
import { Component, setIcon, Menu, Notice, Modal } from "obsidian";
import { Component, setIcon, Menu, Notice, Modal, Platform } from "obsidian";
import { WorkspaceSelector } from "./WorkspaceSelector";
import { ProjectList } from "@/experimental/v2/components/ProjectList";
import { V2NavigationItem } from "@/experimental/v2/types";
@ -26,6 +26,8 @@ export class V2Sidebar extends Component {
private collapsed: boolean = false;
private currentWorkspaceId: string;
private isTreeView: boolean = false;
private otherViewsSection: HTMLElement | null = null;
private railEl: HTMLElement | null = null;
private primaryItems: V2NavigationItem[] = [
{ id: "inbox", label: t("Inbox"), icon: "inbox", type: "primary" },
@ -81,92 +83,13 @@ export class V2Sidebar extends Component {
this.containerEl.addClass("v2-sidebar");
this.containerEl.toggleClass("is-collapsed", this.collapsed);
// Collapsed rail mode: show compact icons for workspace, views, projects, and add
if (this.collapsed) {
const rail = this.containerEl.createDiv({ cls: "v2-sidebar-rail" });
// Workspace menu button
const wsBtn = rail.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("Workspace") },
// Desktop: show rail mode when collapsed
// Mobile: always render full sidebar (CSS handles visibility)
if (this.collapsed && !Platform.isPhone) {
this.railEl = this.containerEl.createDiv({
cls: "v2-sidebar-rail",
});
setIcon(wsBtn, "layers");
wsBtn.addEventListener("click", (e) =>
this.showWorkspaceMenuWithManager(e as MouseEvent)
);
// Primary view icons
this.primaryItems.forEach((item) => {
const btn = rail.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": item.label, "data-view-id": item.id },
});
setIcon(btn, item.icon);
btn.addEventListener("click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
btn.addEventListener("contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
// Other view icons with overflow menu when > 5
const allOtherItems = this.computeOtherItems();
const visibleCount =
this.plugin?.settings?.experimental?.v2Config
?.maxOtherViewsBeforeOverflow ?? 5;
const displayedOther: V2NavigationItem[] = allOtherItems.slice(
0,
visibleCount
);
const remainingOther: V2NavigationItem[] =
allOtherItems.slice(visibleCount);
displayedOther.forEach((item: V2NavigationItem) => {
const btn = rail.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": item.label, "data-view-id": item.id },
});
setIcon(btn, item.icon);
btn.addEventListener("click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
btn.addEventListener("contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
if (remainingOther.length > 0) {
const moreBtn = rail.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("More views") },
});
setIcon(moreBtn, "more-horizontal");
moreBtn.addEventListener("click", (e) =>
this.showOtherViewsMenu(e as MouseEvent, remainingOther)
);
}
// Projects menu button
const projBtn = rail.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("Projects") },
});
setIcon(projBtn, "folder");
projBtn.addEventListener("click", (e) =>
this.showProjectMenu(e as MouseEvent)
);
// Add (New Task) button
const addBtn = rail.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("New Task") },
});
setIcon(addBtn, "plus");
addBtn.addEventListener("click", () => this.onNavigate("new-task"));
this.renderRailMode();
return;
}
@ -265,19 +188,131 @@ export class V2Sidebar extends Component {
this.addChild(this.projectList);
// Other views section
const otherSection = content.createDiv({ cls: "v2-sidebar-section" });
const otherHeader = otherSection.createDiv({
cls: "v2-section-header",
this.otherViewsSection = content.createDiv({
cls: "v2-sidebar-section",
});
this.renderOtherViewsSection();
}
private renderRailMode() {
if (!this.railEl) {
return;
}
// Clear existing content
this.railEl.empty();
// Workspace menu button
const wsBtn = this.railEl.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("Workspace") },
});
setIcon(wsBtn, "layers");
wsBtn.addEventListener("click", (e) =>
this.showWorkspaceMenuWithManager(e as MouseEvent)
);
// Primary view icons
this.primaryItems.forEach((item) => {
const btn = this.railEl!.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": item.label, "data-view-id": item.id },
});
setIcon(btn, item.icon);
btn.addEventListener("click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
btn.addEventListener("contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
// Other view icons with overflow menu when > 5
const allOtherItems = this.computeOtherItems();
const visibleCount = 5;
const visibleCount =
this.plugin?.settings?.experimental?.v2Config
?.maxOtherViewsBeforeOverflow ?? 5;
const displayedOther: V2NavigationItem[] = allOtherItems.slice(
0,
visibleCount
);
const remainingOther: V2NavigationItem[] =
allOtherItems.slice(visibleCount);
displayedOther.forEach((item: V2NavigationItem) => {
const btn = this.railEl!.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": item.label, "data-view-id": item.id },
});
setIcon(btn, item.icon);
btn.addEventListener("click", () => {
this.setActiveItem(item.id);
this.onNavigate(item.id);
});
// Add context menu handler for rail button
btn.addEventListener("contextmenu", (e) => {
this.showViewContextMenu(e as MouseEvent, item.id);
});
});
if (remainingOther.length > 0) {
const moreBtn = this.railEl!.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("More views") },
});
setIcon(moreBtn, "more-horizontal");
moreBtn.addEventListener("click", (e) =>
this.showOtherViewsMenu(e as MouseEvent, remainingOther)
);
}
// Projects menu button
const projBtn = this.railEl!.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("Projects") },
});
setIcon(projBtn, "folder");
projBtn.addEventListener("click", (e) =>
this.showProjectMenu(e as MouseEvent)
);
// Add (New Task) button
const addBtn = this.railEl!.createDiv({
cls: "v2-rail-btn",
attr: { "aria-label": t("New Task") },
});
setIcon(addBtn, "plus");
addBtn.addEventListener("click", () => this.onNavigate("new-task"));
}
private renderOtherViewsSection() {
if (!this.otherViewsSection || this.collapsed) {
return;
}
// Clear existing content
this.otherViewsSection.empty();
// Create header
const otherHeader = this.otherViewsSection.createDiv({
cls: "v2-section-header",
});
const allOtherItems = this.computeOtherItems();
const visibleCount =
this.plugin?.settings?.experimental?.v2Config
?.maxOtherViewsBeforeOverflow ?? 5;
const displayedOther: V2NavigationItem[] = allOtherItems.slice(
0,
visibleCount
);
const remainingOther: V2NavigationItem[] =
allOtherItems.slice(visibleCount);
otherHeader.createSpan({ text: t("Other Views") });
if (remainingOther.length > 0) {
const moreBtn = otherHeader.createDiv({
cls: "v2-section-action",
@ -288,7 +323,8 @@ export class V2Sidebar extends Component {
this.showOtherViewsMenu(e as MouseEvent, remainingOther)
);
}
this.renderNavigationItems(otherSection, displayedOther);
this.renderNavigationItems(this.otherViewsSection, displayedOther);
}
private computeOtherItems(): V2NavigationItem[] {
@ -323,7 +359,19 @@ export class V2Sidebar extends Component {
}
onload() {
this.render();
// On mobile, ensure we render the full sidebar content
// even though it starts "collapsed" (hidden off-screen)
if (Platform.isPhone && this.collapsed) {
// Temporarily set to not collapsed to render full content
const wasCollapsed = this.collapsed;
this.collapsed = false;
this.render();
this.collapsed = wasCollapsed;
// Apply the collapsed class for CSS positioning
this.containerEl.addClass("is-collapsed");
} else {
this.render();
}
// Subscribe to workspace events
if (this.plugin.workspaceManager) {
@ -355,7 +403,14 @@ export class V2Sidebar extends Component {
public setCollapsed(collapsed: boolean) {
this.collapsed = collapsed;
this.render();
// On mobile, don't re-render when toggling collapse
// The CSS will handle the drawer animation
if (!Platform.isPhone) {
this.render();
} else {
// Just toggle the class for mobile
this.containerEl.toggleClass("is-collapsed", collapsed);
}
}
private async handleWorkspaceChange(workspaceId: string) {
@ -676,12 +731,11 @@ export class V2Sidebar extends Component {
}
view.visible = false;
this.plugin.saveSettings();
// Remove the element from DOM instead of full re-render
const element = this.containerEl.querySelector(
`[data-view-id="${viewId}"]`
);
if (element) {
element.remove();
// Re-render based on current mode
if (this.collapsed) {
this.renderRailMode();
} else {
this.renderOtherViewsSection();
}
// Trigger view config changed event
this.plugin.app.workspace.trigger(
@ -708,12 +762,11 @@ export class V2Sidebar extends Component {
(v) => v.id !== viewId
);
this.plugin.saveSettings();
// Remove the element from DOM instead of full re-render
const element = this.containerEl.querySelector(
`[data-view-id="${viewId}"]`
);
if (element) {
element.remove();
// Re-render based on current mode
if (this.collapsed) {
this.renderRailMode();
} else {
this.renderOtherViewsSection();
}
// Trigger view config changed event
this.plugin.app.workspace.trigger(

View file

@ -1,11 +1,18 @@
import { setIcon, Menu, Notice, SearchComponent } from "obsidian";
import {
setIcon,
Menu,
Notice,
SearchComponent,
Platform,
Component,
} from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { Task } from "@/types/task";
import { t } from "@/translations/helper";
export type ViewMode = "list" | "kanban" | "tree" | "calendar";
export class TopNavigation {
export class TopNavigation extends Component {
private containerEl: HTMLElement;
private plugin: TaskProgressBarPlugin;
private searchInput: HTMLInputElement;
@ -22,8 +29,10 @@ export class TopNavigation {
private onFilterClick: () => void,
private onSortClick: () => void,
private onSettingsClick: () => void,
availableModes?: ViewMode[]
availableModes?: ViewMode[],
private onToggleSidebar?: () => void
) {
super();
this.containerEl = containerEl;
this.plugin = plugin;
if (availableModes) {
@ -73,8 +82,9 @@ export class TopNavigation {
// Show navigation when modes are available
this.containerEl.style.display = "";
// Left section - Search
// Left section - Hamburger menu (mobile) and Search
const leftSection = this.containerEl.createDiv({ cls: "v2-nav-left" });
const searchContainer = leftSection.createDiv({
cls: "v2-search-container",
});
@ -91,7 +101,9 @@ export class TopNavigation {
});
// Render view tabs (we know modes are available at this point)
this.viewTabsContainer = centerSection.createDiv({ cls: "v2-view-tabs" });
this.viewTabsContainer = centerSection.createDiv({
cls: "v2-view-tabs",
});
this.renderViewTabs();
// Right section - Notifications and Settings
@ -203,7 +215,11 @@ export class TopNavigation {
item.setTitle(task.content || t("Untitled task"))
.setIcon("alert-circle")
.onClick(() => {
new Notice(t("Task: {{content}}", { content: task.content || "" }));
new Notice(
t("Task: {{content}}", {
content: task.content || "",
})
);
});
});
});
@ -254,7 +270,12 @@ export class TopNavigation {
for (const mode of this.availableModes) {
const config = modeConfig[mode];
if (config) {
this.createViewTab(this.viewTabsContainer, mode, config.icon, config.label);
this.createViewTab(
this.viewTabsContainer,
mode,
config.icon,
config.label
);
}
}
}
@ -279,12 +300,16 @@ export class TopNavigation {
}
// Update center section visibility (this should always be visible now since we handle empty modes above)
const centerSection = this.containerEl.querySelector(".v2-nav-center") as HTMLElement;
const centerSection = this.containerEl.querySelector(
".v2-nav-center"
) as HTMLElement;
if (centerSection) {
centerSection.style.display = "";
// Re-render the view tabs
if (!this.viewTabsContainer) {
this.viewTabsContainer = centerSection.createDiv({ cls: "v2-view-tabs" });
this.viewTabsContainer = centerSection.createDiv({
cls: "v2-view-tabs",
});
}
this.renderViewTabs();
}

View file

@ -406,28 +406,9 @@
position: relative;
display: flex;
align-items: center;
}
.v2-search-icon {
position: absolute;
left: 12px;
color: var(--text-muted);
pointer-events: none;
}
.v2-search-input {
width: 100%;
padding: 8px 12px 8px 36px;
background-color: var(--background-modifier-form-field);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
font-size: 14px;
color: var(--text-normal);
}
.v2-search-input:focus {
outline: none;
border-color: var(--interactive-accent);
max-width: 400px;
flex: 1;
}
/* Center Navigation */
@ -564,7 +545,7 @@
/* Responsive adjustments */
@media (max-width: 768px) {
.tg-v2-sidebar-container {
.tg-v2-sidebar-container:not(.is-mobile-drawer) {
width: 200px;
}
@ -581,6 +562,37 @@
}
}
/* Mobile-specific media query */
@media (max-width: 480px) {
/* Ensure drawer takes appropriate width on small screens */
.tg-v2-sidebar-container.is-mobile-drawer {
width: 75vw !important;
max-width: 320px !important;
}
/* Make hamburger button more prominent on mobile */
.v2-hamburger-button {
width: 44px;
height: 44px;
}
/* Adjust top navigation spacing on mobile */
.v2-top-navigation {
padding: 0 12px;
}
/* Hide view tabs on very small screens */
.v2-view-tabs {
display: none;
}
/* Optimize search container on mobile */
.v2-search-container {
flex: 1;
max-width: calc(100vw - 180px);
}
}
/* ===== Collapsed Sidebar (Rail) ===== */
.tg-v2-sidebar-container {
transition: width 0.2s ease, min-width 0.2s ease, max-width 0.2s ease;
@ -653,3 +665,190 @@
text-transform: uppercase;
padding-top: 1px;
}
/* ===== Mobile Drawer Styles ===== */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.drawer-open .drawer-overlay {
opacity: 1;
pointer-events: auto;
}
/* Mobile drawer sidebar */
.tg-v2-sidebar-container.is-mobile-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px !important;
max-width: 85vw !important;
min-width: 280px !important;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.15);
}
/* When drawer is open */
.drawer-open .tg-v2-sidebar-container.is-mobile-drawer {
transform: translateX(0);
}
/* Ensure sidebar content is visible on mobile even when "collapsed" */
.tg-v2-sidebar-container.is-mobile-drawer .v2-sidebar.is-collapsed {
display: flex;
width: 100%;
}
/* Hide rail mode on mobile */
.tg-v2-sidebar-container.is-mobile-drawer .v2-sidebar-rail {
display: none;
}
/* Mobile-specific sidebar content adjustments */
.is-mobile .v2-sidebar {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.is-mobile .v2-sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--background-modifier-border);
}
/* Ensure all sections are visible on mobile */
.is-mobile .v2-sidebar-content {
overflow-y: auto;
max-height: calc(
100vh - 150px
); /* Account for header and new task button */
}
.is-mobile .v2-sidebar-section {
display: block !important;
visibility: visible !important;
}
/* Ensure section headers are visible on mobile */
.is-mobile .v2-section-header {
display: flex !important;
visibility: visible !important;
}
.is-mobile .v2-navigation-item,
.is-mobile .v2-project-item {
padding: 10px 16px;
font-size: 15px;
}
.is-mobile .v2-new-task-btn {
padding: 12px;
font-size: 15px;
}
.is-mobile .v2-navigation-list {
display: flex;
justify-content: center;
}
.is-mobile span.v2-navigation-label {
display: none;
}
/* Touch-friendly sizing */
.is-mobile .v2-navigation-item,
.is-mobile .v2-project-item,
.is-mobile .v2-add-project {
min-height: 44px;
}
/* Adjust main container on mobile when drawer is open */
.drawer-open .tg-v2-main-container {
pointer-events: none;
}
/* Hamburger menu button for mobile */
.v2-hamburger-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
margin-right: 8px;
flex-shrink: 0;
}
.v2-hamburger-button:hover {
background-color: var(--background-modifier-hover);
color: var(--text-normal);
}
.v2-hamburger-button svg {
width: 20px;
height: 20px;
}
/* Adjust search container on mobile when hamburger is present */
.v2-nav-left {
display: flex;
align-items: center;
gap: 8px;
}
/* Hide sidebar toggle on desktop when in mobile drawer mode */
.tg-v2-sidebar-container.is-mobile-drawer
~ .tg-v2-main-container
.sidebar-toggle {
display: none !important;
}
/* Animation for drawer from right (optional - can be configured) */
.tg-v2-sidebar-container.is-mobile-drawer.drawer-right {
left: auto;
right: 0;
transform: translateX(100%);
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15);
}
.drawer-open .tg-v2-sidebar-container.is-mobile-drawer.drawer-right {
transform: translateX(0);
}
/* Smooth animations for all transitions */
@keyframes slideInFromLeft {
from {
transform: translateX(-100%);
opacity: 0.8;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
opacity: 0.8;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View file

@ -34,7 +34,8 @@
}
/* Mobile view - slide from right */
.is-phone .task-details {
.is-phone .task-details,
.is-mobile .task-details {
position: absolute;
right: 0;
top: 0;
@ -45,19 +46,25 @@
transform: translateX(100%);
}
.is-phone .task-genius-container.details-hidden .task-details {
.is-phone .task-genius-container.details-hidden .task-details,
.is-mobile .task-genius-container.details-hidden .task-details,
.is-mobile .tg-v2-container.details-hidden .task-details {
width: 100%;
margin-right: 0;
transform: translateX(100%);
}
.is-phone .task-genius-container.details-visible .task-details {
.is-phone .task-genius-container.details-visible .task-details,
.is-mobile .task-genius-container.details-visible .task-details,
.is-mobile .tg-v2-container.details-visible .task-details {
width: calc(100% - var(--size-4-12));
transform: translateX(0);
}
/* Add overlay when details are visible on mobile */
.is-phone .task-genius-container.details-visible::before {
.is-phone .task-genius-container.details-visible::before,
.is-mobile .task-genius-container.details-visible::before,
.is-mobile .tg-v2-container.details-visible::before {
content: "";
position: absolute;
top: 0;
@ -70,7 +77,8 @@
transition: opacity 0.3s ease-in-out;
}
.is-phone .details-close-btn {
.is-phone .details-close-btn,
.is-mobile .details-close-btn {
width: 24px;
height: 24px;
display: flex;
@ -78,7 +86,8 @@
justify-content: center;
}
.is-phone .details-header {
.is-phone .details-header,
.is-mobile .details-header {
padding: var(--size-4-4);
}
@ -341,6 +350,28 @@
background-color: var(--interactive-hover);
}
/* V2 Container specific styles for mobile */
.is-mobile .tg-v2-container .task-details {
position: fixed;
right: 0;
top: 0;
bottom: 0;
height: 100vh;
width: 85vw;
max-width: 400px;
z-index: 1001; /* Above drawer overlay */
background-color: var(--background-secondary);
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.2);
}
.is-mobile .task-details .details-close-btn:hover {
background-color: var(--background-modifier-active-hover);
}
.is-mobile .details-content {
padding-bottom: var(--size-4-12);
}
/* Responsive design for mobile */
@media screen and (max-width: 768px) {
.task-omnifocus-container {

File diff suppressed because one or more lines are too long