@hienlh/ppm 0.5.0 → 0.5.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.
@@ -34,7 +34,8 @@ function ProjectAvatar({ name, color, allNames }: { name: string; color: string;
34
34
  }
35
35
 
36
36
  export function ProjectBottomSheet({ isOpen, onClose }: ProjectBottomSheetProps) {
37
- const { projects, activeProject, setActiveProject, setProjectColor, moveProject, renameProject, deleteProject, customOrder } = useProjectStore();
37
+ const { projects, activeProject, setActiveProject, setProjectColor, reorderProjects, renameProject, deleteProject, customOrder } = useProjectStore();
38
+
38
39
  const openTab = useTabStore((s) => s.openTab);
39
40
  const version = useSettingsStore((s) => s.version);
40
41
 
@@ -77,10 +78,16 @@ export function ProjectBottomSheet({ isOpen, onClose }: ProjectBottomSheetProps)
77
78
  }
78
79
 
79
80
  function handleSettings() {
81
+ handleClose();
82
+ // Mobile: open drawer with settings tab
83
+ if (window.innerWidth < 768) {
84
+ window.dispatchEvent(new Event("open-mobile-settings"));
85
+ return;
86
+ }
87
+ // Desktop: open sidebar settings tab
80
88
  const { sidebarCollapsed, toggleSidebar, setSidebarActiveTab } = useSettingsStore.getState();
81
89
  if (sidebarCollapsed) toggleSidebar();
82
90
  setSidebarActiveTab("settings");
83
- handleClose();
84
91
  }
85
92
 
86
93
  async function handleRename() {
@@ -133,16 +140,22 @@ export function ProjectBottomSheet({ isOpen, onClose }: ProjectBottomSheetProps)
133
140
  ...(actionIdx > 0 ? [{
134
141
  label: "Move Up",
135
142
  icon: ChevronUp,
136
- onClick: async () => {
137
- await moveProject(actionTarget, "up");
143
+ onClick: () => {
144
+ const names = ordered.map((p) => p.name);
145
+ const [moved] = names.splice(actionIdx, 1);
146
+ names.splice(actionIdx - 1, 0, moved!);
147
+ reorderProjects(names);
138
148
  setActionTarget(null);
139
149
  },
140
150
  }] : []),
141
151
  ...(actionIdx < ordered.length - 1 ? [{
142
152
  label: "Move Down",
143
153
  icon: ChevronDown,
144
- onClick: async () => {
145
- await moveProject(actionTarget, "down");
154
+ onClick: () => {
155
+ const names = ordered.map((p) => p.name);
156
+ const [moved] = names.splice(actionIdx, 1);
157
+ names.splice(actionIdx + 1, 0, moved!);
158
+ reorderProjects(names);
146
159
  setActionTarget(null);
147
160
  },
148
161
  }] : []),
@@ -96,7 +96,7 @@ export function Sidebar() {
96
96
  )}
97
97
  >
98
98
  <Icon className="size-3.5" />
99
- <span>{tab.label}</span>
99
+ {sidebarWidth >= 240 && <span>{tab.label}</span>}
100
100
  </button>
101
101
  );
102
102
  })}
@@ -18,7 +18,7 @@ const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
18
18
 
19
19
  export function SettingsTab() {
20
20
  const { theme, setTheme } = useSettingsStore();
21
- const { permission, isSubscribed, loading, subscribe, unsubscribe } = usePushNotification();
21
+ const { permission, isSubscribed, loading, error: pushError, subscribe, unsubscribe } = usePushNotification();
22
22
 
23
23
  return (
24
24
  <div className="h-full w-full overflow-auto">
@@ -78,6 +78,21 @@ export function SettingsTab() {
78
78
  {loading ? "..." : isSubscribed ? "On" : "Off"}
79
79
  </Button>
80
80
  </div>
81
+ {isSubscribed && (
82
+ <Button
83
+ variant="outline"
84
+ size="sm"
85
+ className="h-7 text-xs w-full"
86
+ onClick={() => {
87
+ new Notification("PPM Test", { body: "Push notifications are working!" });
88
+ }}
89
+ >
90
+ Test notification
91
+ </Button>
92
+ )}
93
+ {pushError && (
94
+ <p className="text-[11px] text-destructive">{pushError}</p>
95
+ )}
81
96
  {permission === "denied" && (
82
97
  <p className="text-[11px] text-destructive">
83
98
  Notifications blocked. Enable in browser settings.
@@ -13,6 +13,7 @@ export function usePushNotification() {
13
13
  const [permission, setPermission] = useState<NotificationPermission>("default");
14
14
  const [isSubscribed, setIsSubscribed] = useState(false);
15
15
  const [loading, setLoading] = useState(false);
16
+ const [error, setError] = useState<string | null>(null);
16
17
 
17
18
  // Check current permission and subscription state on mount
18
19
  useEffect(() => {
@@ -24,13 +25,29 @@ export function usePushNotification() {
24
25
 
25
26
  const subscribe = useCallback(async () => {
26
27
  setLoading(true);
28
+ setError(null);
27
29
  try {
28
30
  // 1. Request notification permission
29
31
  const perm = await Notification.requestPermission();
30
32
  setPermission(perm);
31
- if (perm !== "granted") return;
33
+ if (perm !== "granted") {
34
+ setError("Permission denied");
35
+ return;
36
+ }
37
+
38
+ // 2. Check service worker is available (with timeout)
39
+ if (!navigator.serviceWorker?.controller && !navigator.serviceWorker?.ready) {
40
+ setError("Service worker not available (dev mode?)");
41
+ return;
42
+ }
32
43
 
33
- // 2. Get VAPID public key from server
44
+ const swReady = await Promise.race([
45
+ navigator.serviceWorker.ready,
46
+ new Promise<null>((_, reject) => setTimeout(() => reject(new Error("Service worker timeout")), 5000)),
47
+ ]);
48
+ if (!swReady) throw new Error("Service worker not ready");
49
+
50
+ // 3. Get VAPID public key from server
34
51
  const headers: Record<string, string> = {};
35
52
  const token = getAuthToken();
36
53
  if (token) headers.Authorization = `Bearer ${token}`;
@@ -39,14 +56,13 @@ export function usePushNotification() {
39
56
  const json = await res.json();
40
57
  if (!json.ok) throw new Error(json.error || "Failed to get VAPID key");
41
58
 
42
- // 3. Subscribe via PushManager
43
- const reg = await navigator.serviceWorker.ready;
44
- const sub = await reg.pushManager.subscribe({
59
+ // 4. Subscribe via PushManager
60
+ const sub = await swReady.pushManager.subscribe({
45
61
  userVisibleOnly: true,
46
62
  applicationServerKey: urlBase64ToUint8Array(json.data.publicKey).buffer as ArrayBuffer,
47
63
  });
48
64
 
49
- // 4. Send subscription to server
65
+ // 5. Send subscription to server
50
66
  await fetch("/api/push/subscribe", {
51
67
  method: "POST",
52
68
  headers: { ...headers, "Content-Type": "application/json" },
@@ -56,6 +72,8 @@ export function usePushNotification() {
56
72
  setIsSubscribed(true);
57
73
  localStorage.setItem("ppm-push-subscribed", "true");
58
74
  } catch (err) {
75
+ const msg = err instanceof Error ? err.message : "Subscribe failed";
76
+ setError(msg);
59
77
  console.error("[push] Subscribe failed:", err);
60
78
  } finally {
61
79
  setLoading(false);
@@ -92,5 +110,5 @@ export function usePushNotification() {
92
110
  }
93
111
  }, []);
94
112
 
95
- return { permission, isSubscribed, loading, subscribe, unsubscribe };
113
+ return { permission, isSubscribed, loading, error, subscribe, unsubscribe };
96
114
  }
@@ -92,6 +92,7 @@ interface ProjectStore {
92
92
  addProject: (path: string, name?: string) => Promise<ProjectInfo>;
93
93
  setProjectColor: (name: string, color: string | null) => Promise<void>;
94
94
  moveProject: (name: string, direction: "up" | "down") => Promise<void>;
95
+ reorderProjects: (newOrder: string[]) => Promise<void>;
95
96
  renameProject: (name: string, newName: string) => Promise<void>;
96
97
  deleteProject: (name: string) => Promise<void>;
97
98
  }
@@ -161,6 +162,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
161
162
  set({ customOrder: newOrder });
162
163
  },
163
164
 
165
+ reorderProjects: async (newOrder) => {
166
+ saveCustomOrder(newOrder);
167
+ set({ customOrder: newOrder });
168
+ await api.patch("/api/projects/reorder", { order: newOrder }).catch(() => {});
169
+ },
170
+
164
171
  renameProject: async (name, newName) => {
165
172
  await api.patch(`/api/projects/${encodeURIComponent(name)}`, { name: newName });
166
173
  // Refetch to get updated list