@hienlh/ppm 0.6.7 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +86 -313
- package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
- package/dist/web/assets/{code-editor-BUg7alP6.js → code-editor-D6OuzcC-.js} +1 -1
- package/dist/web/assets/{database-viewer-CAgZOkZc.js → database-viewer-BxUpM_uA.js} +1 -1
- package/dist/web/assets/{diff-viewer-DVvY1aFb.js → diff-viewer-DAhrHpNM.js} +1 -1
- package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
- package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
- package/dist/web/assets/index-BU_07_oW.js +29 -0
- package/dist/web/assets/index-CBQhXXeV.css +2 -0
- package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
- package/dist/web/assets/{markdown-renderer-z99RjIxZ.js → markdown-renderer-CvGYO9sH.js} +2 -2
- package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
- package/dist/web/assets/{settings-tab-BnDkeQWk.js → settings-tab-Bwsxb41F.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-EwHWc37J.js → sqlite-viewer-DfgaCbWT.js} +1 -1
- package/dist/web/assets/{terminal-tab-CTN18lb6.js → terminal-tab-D27e4ZTD.js} +2 -2
- package/dist/web/index.html +4 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/lib/network-utils.ts +12 -0
- package/src/providers/claude-agent-sdk.ts +61 -3
- package/src/server/routes/chat.ts +5 -1
- package/src/server/routes/settings.ts +52 -0
- package/src/server/routes/tunnel.ts +1 -12
- package/src/server/ws/chat.ts +42 -12
- package/src/services/config.service.ts +1 -1
- package/src/services/notification.service.ts +42 -0
- package/src/services/telegram-notification.service.ts +106 -0
- package/src/types/config.ts +6 -0
- package/src/web/app.tsx +40 -1
- package/src/web/components/layout/draggable-tab.tsx +10 -2
- package/src/web/components/layout/mobile-nav.tsx +42 -3
- package/src/web/components/layout/project-bar.tsx +16 -8
- package/src/web/components/layout/tab-bar.tsx +55 -4
- package/src/web/components/settings/settings-tab.tsx +135 -94
- package/src/web/components/settings/telegram-settings-section.tsx +113 -0
- package/src/web/components/ui/accordion.tsx +64 -0
- package/src/web/hooks/use-chat.ts +29 -0
- package/src/web/hooks/use-notification-badge.ts +20 -0
- package/src/web/hooks/use-tab-overflow.ts +91 -0
- package/src/web/hooks/use-url-sync.ts +5 -2
- package/src/web/index.html +1 -0
- package/src/web/lib/favicon.ts +21 -0
- package/src/web/lib/notification-sounds.ts +61 -0
- package/src/web/stores/notification-store.ts +83 -0
- package/dist/web/assets/chat-tab-CWhxhPKH.js +0 -7
- package/dist/web/assets/git-graph-xD6TLRVv.js +0 -1
- package/dist/web/assets/index-CigdXBuQ.css +0 -2
- package/dist/web/assets/index-DBdw8tN_.js +0 -22
- package/dist/web/assets/keybindings-store-kHLASnRb.js +0 -1
- package/dist/web/assets/postgres-viewer-CaMySHpD.js +0 -1
- /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
|
@@ -3,12 +3,15 @@ import { X } from "lucide-react";
|
|
|
3
3
|
import type { Tab, TabType } from "@/stores/tab-store";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
5
|
import { isDarkColor } from "@/lib/color-utils";
|
|
6
|
+
import { notificationColor } from "@/stores/notification-store";
|
|
6
7
|
|
|
7
8
|
interface DraggableTabProps {
|
|
8
9
|
tab: Tab;
|
|
9
10
|
isActive: boolean;
|
|
10
11
|
icon: React.ElementType;
|
|
11
12
|
showDropBefore: boolean;
|
|
13
|
+
/** Notification type if unread (null = no unread). Controls badge color. */
|
|
14
|
+
notificationType?: string | null;
|
|
12
15
|
onSelect: () => void;
|
|
13
16
|
onClose: () => void;
|
|
14
17
|
onDragStart: (e: React.DragEvent) => void;
|
|
@@ -20,7 +23,7 @@ interface DraggableTabProps {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export function DraggableTab({
|
|
23
|
-
tab, isActive, icon: Icon, showDropBefore, onSelect, onClose,
|
|
26
|
+
tab, isActive, icon: Icon, showDropBefore, notificationType, onSelect, onClose,
|
|
24
27
|
onDragStart, onDragOver, onDragEnd, tabRef, onRename,
|
|
25
28
|
}: DraggableTabProps) {
|
|
26
29
|
const [editing, setEditing] = useState(false);
|
|
@@ -74,7 +77,12 @@ export function DraggableTab({
|
|
|
74
77
|
colorStyle && "border-transparent",
|
|
75
78
|
)}
|
|
76
79
|
>
|
|
77
|
-
<
|
|
80
|
+
<span className="relative">
|
|
81
|
+
<Icon className="size-4" />
|
|
82
|
+
{notificationType && !isActive && (
|
|
83
|
+
<span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notificationType))} />
|
|
84
|
+
)}
|
|
85
|
+
</span>
|
|
78
86
|
{editing ? (
|
|
79
87
|
<input
|
|
80
88
|
ref={inputRef}
|
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
Terminal, MessageSquare, GitBranch, Database,
|
|
4
4
|
FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
|
|
5
|
+
ChevronLeft, ChevronRight,
|
|
5
6
|
} from "lucide-react";
|
|
6
7
|
import { usePanelStore } from "@/stores/panel-store";
|
|
7
8
|
import { useProjectStore, resolveOrder } from "@/stores/project-store";
|
|
@@ -11,6 +12,8 @@ import { getProjectInitials } from "@/lib/project-avatar";
|
|
|
11
12
|
import type { TabType } from "@/stores/tab-store";
|
|
12
13
|
import { cn } from "@/lib/utils";
|
|
13
14
|
import { openCommandPalette } from "@/hooks/use-global-keybindings";
|
|
15
|
+
import { useNotificationStore, notificationColor } from "@/stores/notification-store";
|
|
16
|
+
import { useTabOverflow, getHiddenUnreadDirection } from "@/hooks/use-tab-overflow";
|
|
14
17
|
|
|
15
18
|
const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
16
19
|
{ type: "terminal", label: "Terminal" },
|
|
@@ -35,7 +38,12 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
|
|
|
35
38
|
const tabs = panel?.tabs ?? [];
|
|
36
39
|
const activeTabId = panel?.activeTabId ?? null;
|
|
37
40
|
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
|
41
|
+
const mobileScrollRef = useRef<HTMLDivElement>(null);
|
|
38
42
|
const prevTabCount = useRef(tabs.length);
|
|
43
|
+
const notifications = useNotificationStore((s) => s.notifications);
|
|
44
|
+
const { canScrollLeft, canScrollRight, scrollLeft: doScrollLeft, scrollRight: doScrollRight } =
|
|
45
|
+
useTabOverflow(mobileScrollRef);
|
|
46
|
+
const hiddenUnread = getHiddenUnreadDirection(mobileScrollRef.current, tabRefs.current as Map<string, HTMLElement>, tabs, notifications);
|
|
39
47
|
|
|
40
48
|
const [menuTabId, setMenuTabId] = useState<string | null>(null);
|
|
41
49
|
const [newTabSheetOpen, setNewTabSheetOpen] = useState(false);
|
|
@@ -109,15 +117,31 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
|
|
|
109
117
|
<Menu className="size-5" />
|
|
110
118
|
</button>
|
|
111
119
|
|
|
112
|
-
<div className="flex-1 flex items-center h-12
|
|
120
|
+
<div className="flex-1 relative flex items-center h-12">
|
|
121
|
+
{/* Left scroll arrow */}
|
|
122
|
+
{canScrollLeft && (
|
|
123
|
+
<button onClick={doScrollLeft} className="absolute left-0 z-10 flex items-center justify-center size-8 bg-gradient-to-r from-background via-background to-transparent">
|
|
124
|
+
<span className="relative">
|
|
125
|
+
<ChevronLeft className="size-3.5 text-text-secondary" />
|
|
126
|
+
{hiddenUnread.left && <span className={cn("absolute -top-1 -right-0.5 size-1.5 rounded-full", notificationColor(hiddenUnread.left))} />}
|
|
127
|
+
</span>
|
|
128
|
+
</button>
|
|
129
|
+
)}
|
|
130
|
+
<div ref={mobileScrollRef} className="flex-1 flex items-center h-12 overflow-x-auto scrollbar-none">
|
|
113
131
|
{tabs.map((tab) => {
|
|
114
132
|
const Icon = TAB_ICONS[tab.type];
|
|
115
133
|
const isActive = tab.id === activeTabId;
|
|
134
|
+
const sessionId = tab.type === "chat" ? (tab.metadata?.sessionId as string) : undefined;
|
|
135
|
+
const entry = sessionId ? notifications.get(sessionId) : undefined;
|
|
136
|
+
const notiType = entry && entry.count > 0 ? entry.type : null;
|
|
116
137
|
return (
|
|
117
138
|
<button
|
|
118
139
|
key={tab.id}
|
|
119
140
|
ref={(el) => { if (el) tabRefs.current.set(tab.id, el); else tabRefs.current.delete(tab.id); }}
|
|
120
|
-
onClick={() =>
|
|
141
|
+
onClick={() => {
|
|
142
|
+
usePanelStore.getState().setActiveTab(tab.id);
|
|
143
|
+
if (sessionId) useNotificationStore.getState().clearForSession(sessionId);
|
|
144
|
+
}}
|
|
121
145
|
onTouchStart={() => startLongPress(tab.id)}
|
|
122
146
|
onTouchEnd={cancelLongPress}
|
|
123
147
|
onTouchMove={cancelLongPress}
|
|
@@ -127,7 +151,12 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
|
|
|
127
151
|
isActive ? "border-primary bg-surface text-primary" : "border-transparent text-text-secondary",
|
|
128
152
|
)}
|
|
129
153
|
>
|
|
130
|
-
<
|
|
154
|
+
<span className="relative">
|
|
155
|
+
<Icon className="size-4" />
|
|
156
|
+
{notiType && !isActive && (
|
|
157
|
+
<span className={cn("absolute -top-1 -right-1 size-2 rounded-full", notificationColor(notiType))} />
|
|
158
|
+
)}
|
|
159
|
+
</span>
|
|
131
160
|
<span className="max-w-[80px] truncate">{tab.title}</span>
|
|
132
161
|
{tab.closable && (
|
|
133
162
|
<span role="button" tabIndex={0} onClick={(e) => { e.stopPropagation(); usePanelStore.getState().closeTab(tab.id); }}
|
|
@@ -138,6 +167,16 @@ export function MobileNav({ onMenuPress, onProjectsPress }: MobileNavProps) {
|
|
|
138
167
|
</button>
|
|
139
168
|
);
|
|
140
169
|
})}
|
|
170
|
+
</div>
|
|
171
|
+
{/* Right scroll arrow */}
|
|
172
|
+
{canScrollRight && (
|
|
173
|
+
<button onClick={doScrollRight} className="absolute right-0 z-10 flex items-center justify-center size-8 bg-gradient-to-l from-background via-background to-transparent">
|
|
174
|
+
<span className="relative">
|
|
175
|
+
<ChevronRight className="size-3.5 text-text-secondary" />
|
|
176
|
+
{hiddenUnread.right && <span className={cn("absolute -top-1 -left-0.5 size-1.5 rounded-full", notificationColor(hiddenUnread.right))} />}
|
|
177
|
+
</span>
|
|
178
|
+
</button>
|
|
179
|
+
)}
|
|
141
180
|
</div>
|
|
142
181
|
|
|
143
182
|
{/* Add tab — opens command palette */}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useEffect } from "react";
|
|
1
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { Plus, Settings, Pencil, Trash2, Palette, Bug, Share2, Loader2, Copy, Check, X } from "lucide-react";
|
|
4
4
|
import { QRCodeSVG } from "qrcode.react";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "@/components/ui/dialog";
|
|
26
26
|
import { AddProjectForm } from "@/components/layout/add-project-form";
|
|
27
27
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
28
|
+
import { useNotificationStore, selectProjectUrgentType, notificationColor } from "@/stores/notification-store";
|
|
28
29
|
import { cn } from "@/lib/utils";
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -34,15 +35,22 @@ function ProjectAvatar({ name, color, active, allNames }: {
|
|
|
34
35
|
name: string; color: string; active: boolean; allNames: string[];
|
|
35
36
|
}) {
|
|
36
37
|
const initials = getProjectInitials(name, allNames);
|
|
38
|
+
const selector = useMemo(() => selectProjectUrgentType(name), [name]);
|
|
39
|
+
const urgentType = useNotificationStore(selector);
|
|
37
40
|
return (
|
|
38
|
-
<div
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
<div className="relative">
|
|
42
|
+
<div
|
|
43
|
+
className={cn(
|
|
44
|
+
"size-10 rounded-full flex items-center justify-center text-xs font-bold text-white select-none shrink-0",
|
|
45
|
+
active && "ring-2 ring-primary ring-offset-2 ring-offset-background",
|
|
46
|
+
)}
|
|
47
|
+
style={{ background: color }}
|
|
48
|
+
>
|
|
49
|
+
{initials}
|
|
50
|
+
</div>
|
|
51
|
+
{urgentType && (
|
|
52
|
+
<div className={cn("absolute -top-0.5 -right-0.5 size-2.5 rounded-full border-2 border-background", notificationColor(urgentType))} />
|
|
42
53
|
)}
|
|
43
|
-
style={{ background: color }}
|
|
44
|
-
>
|
|
45
|
-
{initials}
|
|
46
54
|
</div>
|
|
47
55
|
);
|
|
48
56
|
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
Settings,
|
|
9
9
|
FileCode,
|
|
10
10
|
Database,
|
|
11
|
+
ChevronLeft,
|
|
12
|
+
ChevronRight,
|
|
11
13
|
} from "lucide-react";
|
|
12
14
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
13
15
|
import { usePanelStore } from "@/stores/panel-store";
|
|
@@ -15,7 +17,10 @@ import { useProjectStore } from "@/stores/project-store";
|
|
|
15
17
|
import { useTabDrag } from "@/hooks/use-tab-drag";
|
|
16
18
|
import { openCommandPalette } from "@/hooks/use-global-keybindings";
|
|
17
19
|
import { api, projectUrl } from "@/lib/api-client";
|
|
20
|
+
import { useNotificationStore, notificationColor } from "@/stores/notification-store";
|
|
21
|
+
import { useTabOverflow, getHiddenUnreadDirection } from "@/hooks/use-tab-overflow";
|
|
18
22
|
import { DraggableTab } from "./draggable-tab";
|
|
23
|
+
import { cn } from "@/lib/utils";
|
|
19
24
|
import type { Tab } from "@/stores/tab-store";
|
|
20
25
|
|
|
21
26
|
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
@@ -49,6 +54,13 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
49
54
|
const { dropIndex, handleDragStart, handleDragOver, handleDragOverBar, handleDrop, handleDragEnd } =
|
|
50
55
|
useTabDrag(effectivePanelId);
|
|
51
56
|
|
|
57
|
+
const notifications = useNotificationStore((s) => s.notifications);
|
|
58
|
+
const { canScrollLeft, canScrollRight, scrollLeft: doScrollLeft, scrollRight: doScrollRight } =
|
|
59
|
+
useTabOverflow(scrollRef);
|
|
60
|
+
|
|
61
|
+
// Hidden unread direction — recomputed when notifications or scroll changes
|
|
62
|
+
const hiddenUnread = getHiddenUnreadDirection(scrollRef.current, tabRefs.current as Map<string, HTMLElement>, tabs, notifications);
|
|
63
|
+
|
|
52
64
|
// Auto-scroll to new tab
|
|
53
65
|
useEffect(() => {
|
|
54
66
|
if (tabs.length > prevTabCount.current && activeTabId) {
|
|
@@ -86,26 +98,49 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
86
98
|
|
|
87
99
|
return (
|
|
88
100
|
<div
|
|
89
|
-
className="hidden md:flex items-center h-10 border-b border-border bg-background"
|
|
101
|
+
className="hidden md:flex items-center h-10 border-b border-border bg-background relative"
|
|
90
102
|
onDragOver={handleDragOverBar}
|
|
91
103
|
onDrop={handleDrop}
|
|
92
104
|
onDoubleClick={handleBarDoubleClick}
|
|
93
105
|
onContextMenu={handleBarContextMenu}
|
|
94
106
|
>
|
|
107
|
+
{/* Left scroll arrow */}
|
|
108
|
+
{canScrollLeft && (
|
|
109
|
+
<button
|
|
110
|
+
onClick={doScrollLeft}
|
|
111
|
+
className="absolute left-0 z-10 flex items-center justify-center size-8 bg-gradient-to-r from-background via-background to-transparent"
|
|
112
|
+
>
|
|
113
|
+
<span className="relative">
|
|
114
|
+
<ChevronLeft className="size-4 text-text-secondary" />
|
|
115
|
+
{hiddenUnread.left && (
|
|
116
|
+
<span className={cn("absolute -top-1 -right-0.5 size-2 rounded-full", notificationColor(hiddenUnread.left))} />
|
|
117
|
+
)}
|
|
118
|
+
</span>
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
121
|
+
|
|
95
122
|
{/* Scrollable tabs + sticky + button */}
|
|
96
123
|
<div
|
|
97
124
|
ref={scrollRef}
|
|
98
125
|
className="flex-1 overflow-x-auto overflow-y-hidden min-w-0 scrollbar-none"
|
|
99
126
|
>
|
|
100
127
|
<div className="flex items-center h-10">
|
|
101
|
-
{tabs.map((tab, i) =>
|
|
128
|
+
{tabs.map((tab, i) => {
|
|
129
|
+
const sessionId = tab.type === "chat" ? (tab.metadata?.sessionId as string) : undefined;
|
|
130
|
+
const entry = sessionId ? notifications.get(sessionId) : undefined;
|
|
131
|
+
const notiType = entry && entry.count > 0 ? entry.type : null;
|
|
132
|
+
return (
|
|
102
133
|
<DraggableTab
|
|
103
134
|
key={tab.id}
|
|
104
135
|
tab={tab}
|
|
105
136
|
isActive={tab.id === activeTabId}
|
|
106
137
|
icon={TAB_ICONS[tab.type]}
|
|
107
138
|
showDropBefore={dropIndex === i}
|
|
108
|
-
|
|
139
|
+
notificationType={notiType}
|
|
140
|
+
onSelect={() => {
|
|
141
|
+
usePanelStore.getState().setActiveTab(tab.id, effectivePanelId);
|
|
142
|
+
if (sessionId) useNotificationStore.getState().clearForSession(sessionId);
|
|
143
|
+
}}
|
|
109
144
|
onClose={() => usePanelStore.getState().closeTab(tab.id, effectivePanelId)}
|
|
110
145
|
onDragStart={(e) => handleDragStart(e, tab.id)}
|
|
111
146
|
onDragOver={(e) => handleDragOver(e, tab.id, i)}
|
|
@@ -116,7 +151,8 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
116
151
|
}}
|
|
117
152
|
onRename={tab.type === "chat" ? (title) => handleRenameTab(tab, title) : undefined}
|
|
118
153
|
/>
|
|
119
|
-
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
120
156
|
{/* Show drop indicator at the end */}
|
|
121
157
|
{dropIndex !== null && dropIndex >= tabs.length && (
|
|
122
158
|
<div className="w-0.5 h-6 bg-primary rounded-full" />
|
|
@@ -132,6 +168,21 @@ export function TabBar({ panelId }: TabBarProps) {
|
|
|
132
168
|
</button>
|
|
133
169
|
</div>
|
|
134
170
|
</div>
|
|
171
|
+
|
|
172
|
+
{/* Right scroll arrow */}
|
|
173
|
+
{canScrollRight && (
|
|
174
|
+
<button
|
|
175
|
+
onClick={doScrollRight}
|
|
176
|
+
className="absolute right-10 z-10 flex items-center justify-center size-8 bg-gradient-to-l from-background via-background to-transparent"
|
|
177
|
+
>
|
|
178
|
+
<span className="relative">
|
|
179
|
+
<ChevronRight className="size-4 text-text-secondary" />
|
|
180
|
+
{hiddenUnread.right && (
|
|
181
|
+
<span className={cn("absolute -top-1 -left-0.5 size-2 rounded-full", notificationColor(hiddenUnread.right))} />
|
|
182
|
+
)}
|
|
183
|
+
</span>
|
|
184
|
+
</button>
|
|
185
|
+
)}
|
|
135
186
|
</div>
|
|
136
187
|
);
|
|
137
188
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Moon, Sun, Monitor, Bell, BellOff } from "lucide-react";
|
|
2
2
|
import { Button } from "@/components/ui/button";
|
|
3
|
-
import {
|
|
3
|
+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
4
4
|
import { useSettingsStore, type Theme } from "@/stores/settings-store";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
import { AISettingsSection } from "./ai-settings-section";
|
|
7
7
|
import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
|
|
8
|
+
import { TelegramSettingsSection } from "./telegram-settings-section";
|
|
8
9
|
import { usePushNotification } from "@/hooks/use-push-notification";
|
|
9
10
|
|
|
10
11
|
const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
|
|
@@ -23,107 +24,147 @@ export function SettingsTab() {
|
|
|
23
24
|
|
|
24
25
|
return (
|
|
25
26
|
<div className="h-full w-full overflow-auto">
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
<div className="p-3 space-y-2">
|
|
28
|
+
<h2 className="text-sm font-semibold">Settings</h2>
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
30
|
+
<Accordion type="multiple" defaultValue={["notifications"]}>
|
|
31
|
+
{/* Theme */}
|
|
32
|
+
<AccordionItem value="theme">
|
|
33
|
+
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
34
|
+
Theme
|
|
35
|
+
</AccordionTrigger>
|
|
36
|
+
<AccordionContent className="pb-2">
|
|
37
|
+
<div className="flex gap-1.5">
|
|
38
|
+
{THEME_OPTIONS.map((opt) => {
|
|
39
|
+
const Icon = opt.icon;
|
|
40
|
+
return (
|
|
41
|
+
<Button
|
|
42
|
+
key={opt.value}
|
|
43
|
+
variant={theme === opt.value ? "default" : "outline"}
|
|
44
|
+
size="sm"
|
|
45
|
+
onClick={() => setTheme(opt.value)}
|
|
46
|
+
className={cn(
|
|
47
|
+
"flex-1 gap-1.5 text-xs h-8",
|
|
48
|
+
theme === opt.value && "ring-2 ring-primary",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<Icon className="size-3.5" />
|
|
52
|
+
{opt.label}
|
|
53
|
+
</Button>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</div>
|
|
57
|
+
</AccordionContent>
|
|
58
|
+
</AccordionItem>
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
{/* AI Settings */}
|
|
61
|
+
<AccordionItem value="ai">
|
|
62
|
+
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
63
|
+
AI Settings
|
|
64
|
+
</AccordionTrigger>
|
|
65
|
+
<AccordionContent className="pb-2">
|
|
66
|
+
<AISettingsSection />
|
|
67
|
+
</AccordionContent>
|
|
68
|
+
</AccordionItem>
|
|
58
69
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
{/* Notifications */}
|
|
71
|
+
<AccordionItem value="notifications">
|
|
72
|
+
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
73
|
+
Notifications
|
|
74
|
+
</AccordionTrigger>
|
|
75
|
+
<AccordionContent className="pb-2">
|
|
76
|
+
<div className="space-y-2">
|
|
77
|
+
{/* Push notifications */}
|
|
78
|
+
{!pushSupported ? (
|
|
79
|
+
<p className="text-xs text-text-subtle">
|
|
80
|
+
Push notifications not supported in this browser.
|
|
81
|
+
</p>
|
|
82
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
<div className="flex items-center justify-between">
|
|
85
|
+
<div className="flex items-center gap-1.5">
|
|
86
|
+
{isSubscribed ? <Bell className="size-3.5" /> : <BellOff className="size-3.5" />}
|
|
87
|
+
<span className="text-xs">Push notifications</span>
|
|
88
|
+
</div>
|
|
89
|
+
<Button
|
|
90
|
+
variant={isSubscribed ? "default" : "outline"}
|
|
91
|
+
size="sm"
|
|
92
|
+
className="h-7 text-xs"
|
|
93
|
+
disabled={loading || permission === "denied"}
|
|
94
|
+
onClick={() => (isSubscribed ? unsubscribe() : subscribe())}
|
|
95
|
+
>
|
|
96
|
+
{loading ? "..." : isSubscribed ? "On" : "Off"}
|
|
97
|
+
</Button>
|
|
98
|
+
</div>
|
|
99
|
+
{isSubscribed && (
|
|
100
|
+
<Button
|
|
101
|
+
variant="outline"
|
|
102
|
+
size="sm"
|
|
103
|
+
className="h-7 text-xs w-full"
|
|
104
|
+
onClick={() => {
|
|
105
|
+
new Notification("PPM Test", { body: "Push notifications are working!" });
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
Test notification
|
|
109
|
+
</Button>
|
|
110
|
+
)}
|
|
111
|
+
{pushError && (
|
|
112
|
+
<p className="text-[11px] text-destructive">{pushError}</p>
|
|
113
|
+
)}
|
|
114
|
+
{permission === "denied" && (
|
|
115
|
+
<p className="text-[11px] text-destructive">
|
|
116
|
+
Notifications blocked. Enable in browser settings.
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
{isIosNonPwa && (
|
|
120
|
+
<p className="text-[11px] text-text-subtle">
|
|
121
|
+
On iOS, install PPM to Home Screen for push notifications.
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
71
126
|
</div>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
size="sm"
|
|
75
|
-
className="h-7 text-xs"
|
|
76
|
-
disabled={loading || permission === "denied"}
|
|
77
|
-
onClick={() => (isSubscribed ? unsubscribe() : subscribe())}
|
|
78
|
-
>
|
|
79
|
-
{loading ? "..." : isSubscribed ? "On" : "Off"}
|
|
80
|
-
</Button>
|
|
81
|
-
</div>
|
|
82
|
-
{isSubscribed && (
|
|
83
|
-
<Button
|
|
84
|
-
variant="outline"
|
|
85
|
-
size="sm"
|
|
86
|
-
className="h-7 text-xs w-full"
|
|
87
|
-
onClick={() => {
|
|
88
|
-
new Notification("PPM Test", { body: "Push notifications are working!" });
|
|
89
|
-
}}
|
|
90
|
-
>
|
|
91
|
-
Test notification
|
|
92
|
-
</Button>
|
|
93
|
-
)}
|
|
94
|
-
{pushError && (
|
|
95
|
-
<p className="text-[11px] text-destructive">{pushError}</p>
|
|
96
|
-
)}
|
|
97
|
-
{permission === "denied" && (
|
|
98
|
-
<p className="text-[11px] text-destructive">
|
|
99
|
-
Notifications blocked. Enable in browser settings.
|
|
100
|
-
</p>
|
|
101
|
-
)}
|
|
102
|
-
{isIosNonPwa && (
|
|
103
|
-
<p className="text-[11px] text-text-subtle">
|
|
104
|
-
On iOS, install PPM to Home Screen for push notifications.
|
|
105
|
-
</p>
|
|
106
|
-
)}
|
|
107
|
-
</>
|
|
108
|
-
)}
|
|
109
|
-
</div>
|
|
110
|
-
|
|
111
|
-
<Separator />
|
|
127
|
+
</AccordionContent>
|
|
128
|
+
</AccordionItem>
|
|
112
129
|
|
|
113
|
-
|
|
130
|
+
{/* Telegram */}
|
|
131
|
+
<AccordionItem value="telegram">
|
|
132
|
+
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
133
|
+
Telegram
|
|
134
|
+
</AccordionTrigger>
|
|
135
|
+
<AccordionContent className="pb-2">
|
|
136
|
+
<TelegramSettingsSection />
|
|
137
|
+
</AccordionContent>
|
|
138
|
+
</AccordionItem>
|
|
114
139
|
|
|
115
|
-
|
|
140
|
+
{/* Keyboard Shortcuts */}
|
|
141
|
+
<AccordionItem value="shortcuts">
|
|
142
|
+
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
143
|
+
Keyboard Shortcuts
|
|
144
|
+
</AccordionTrigger>
|
|
145
|
+
<AccordionContent className="pb-2">
|
|
146
|
+
<KeyboardShortcutsSection />
|
|
147
|
+
</AccordionContent>
|
|
148
|
+
</AccordionItem>
|
|
116
149
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
150
|
+
{/* About */}
|
|
151
|
+
<AccordionItem value="about">
|
|
152
|
+
<AccordionTrigger className="py-2 text-xs font-medium text-text-secondary hover:no-underline">
|
|
153
|
+
About
|
|
154
|
+
</AccordionTrigger>
|
|
155
|
+
<AccordionContent className="pb-2">
|
|
156
|
+
<div className="space-y-1.5">
|
|
157
|
+
<p className="text-xs text-text-secondary">
|
|
158
|
+
PPM — Personal Project Manager
|
|
159
|
+
</p>
|
|
160
|
+
<p className="text-[11px] text-text-subtle">
|
|
161
|
+
A mobile-first web IDE for managing your projects.
|
|
162
|
+
</p>
|
|
163
|
+
</div>
|
|
164
|
+
</AccordionContent>
|
|
165
|
+
</AccordionItem>
|
|
166
|
+
</Accordion>
|
|
125
167
|
</div>
|
|
126
168
|
</div>
|
|
127
|
-
</div>
|
|
128
169
|
);
|
|
129
170
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Send } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { api } from "@/lib/api-client";
|
|
6
|
+
|
|
7
|
+
interface TelegramConfig {
|
|
8
|
+
bot_token: string;
|
|
9
|
+
chat_id: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TelegramSettingsSection() {
|
|
13
|
+
const [config, setConfig] = useState<TelegramConfig>({ bot_token: "", chat_id: "" });
|
|
14
|
+
const [tokenInput, setTokenInput] = useState("");
|
|
15
|
+
const [chatIdInput, setChatIdInput] = useState("");
|
|
16
|
+
const [saving, setSaving] = useState(false);
|
|
17
|
+
const [testing, setTesting] = useState(false);
|
|
18
|
+
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
api.get<TelegramConfig>("/api/settings/telegram").then((data) => {
|
|
22
|
+
setConfig(data);
|
|
23
|
+
setChatIdInput(data.chat_id);
|
|
24
|
+
}).catch(() => {});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const save = async () => {
|
|
28
|
+
setSaving(true);
|
|
29
|
+
setStatus(null);
|
|
30
|
+
try {
|
|
31
|
+
const body: Record<string, string> = { chat_id: chatIdInput };
|
|
32
|
+
if (tokenInput) body.bot_token = tokenInput;
|
|
33
|
+
const data = await api.put<TelegramConfig>("/api/settings/telegram", body);
|
|
34
|
+
setConfig(data);
|
|
35
|
+
setTokenInput("");
|
|
36
|
+
setStatus({ type: "ok", msg: "Saved" });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
setStatus({ type: "err", msg: (e as Error).message });
|
|
39
|
+
} finally {
|
|
40
|
+
setSaving(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const test = async () => {
|
|
45
|
+
setTesting(true);
|
|
46
|
+
setStatus(null);
|
|
47
|
+
try {
|
|
48
|
+
// Send current input values; backend falls back to saved config for empty fields
|
|
49
|
+
await api.post("/api/settings/telegram/test", {
|
|
50
|
+
...(tokenInput ? { bot_token: tokenInput } : {}),
|
|
51
|
+
...(chatIdInput ? { chat_id: chatIdInput } : {}),
|
|
52
|
+
});
|
|
53
|
+
setStatus({ type: "ok", msg: "Test message sent!" });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
setStatus({ type: "err", msg: (e as Error).message });
|
|
56
|
+
} finally {
|
|
57
|
+
setTesting(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const isConfigured = !!config.bot_token;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<div className="space-y-1.5">
|
|
66
|
+
<label className="text-[11px] text-text-subtle">Bot Token</label>
|
|
67
|
+
<Input
|
|
68
|
+
type="password"
|
|
69
|
+
placeholder={isConfigured ? "•••••• (saved)" : "123456:ABC-DEF..."}
|
|
70
|
+
value={tokenInput}
|
|
71
|
+
onChange={(e) => setTokenInput(e.target.value)}
|
|
72
|
+
className="h-7 text-xs"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="space-y-1.5">
|
|
76
|
+
<label className="text-[11px] text-text-subtle">Chat ID</label>
|
|
77
|
+
<Input
|
|
78
|
+
placeholder="-1001234567890"
|
|
79
|
+
value={chatIdInput}
|
|
80
|
+
onChange={(e) => setChatIdInput(e.target.value)}
|
|
81
|
+
className="h-7 text-xs"
|
|
82
|
+
/>
|
|
83
|
+
<p className="text-[10px] text-text-subtle">Personal or group chat ID</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="flex gap-1.5">
|
|
86
|
+
<Button
|
|
87
|
+
variant="default"
|
|
88
|
+
size="sm"
|
|
89
|
+
className="h-7 text-xs flex-1"
|
|
90
|
+
disabled={saving || (!tokenInput && !chatIdInput)}
|
|
91
|
+
onClick={save}
|
|
92
|
+
>
|
|
93
|
+
{saving ? "..." : "Save"}
|
|
94
|
+
</Button>
|
|
95
|
+
<Button
|
|
96
|
+
variant="outline"
|
|
97
|
+
size="sm"
|
|
98
|
+
className="h-7 text-xs gap-1"
|
|
99
|
+
disabled={testing || !isConfigured}
|
|
100
|
+
onClick={test}
|
|
101
|
+
>
|
|
102
|
+
<Send className="size-3" />
|
|
103
|
+
{testing ? "..." : "Test"}
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
{status && (
|
|
107
|
+
<p className={`text-[11px] ${status.type === "ok" ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
|
|
108
|
+
{status.msg}
|
|
109
|
+
</p>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|