rogasper commited on
Commit
f3abb72
Β·
1 Parent(s): 92a1d83

feat: add visibility filter for packages and enhance bulk publishing functionality. Introduce visibility options (all, private, public) in the package query and update the UI to support filtering. Implement a callout for private packages with a dismiss option and improve success messages for bulk publishing actions. Update API to handle visibility conditions in package management.

Browse files
apps/web/src/routes/packages.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { z } from "zod";
@@ -17,6 +17,7 @@ import {
17
  } from "@labas/ui/components/select";
18
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
19
  import { GettingStartedCard } from "@/components/GettingStartedCard";
 
20
  import { PageTour, TourHelpButton } from "@/components/TourGuide";
21
  import type { Step } from "react-joyride";
22
  import { toast } from "sonner";
@@ -28,6 +29,7 @@ export const Route = createFileRoute("/packages")({
28
  search: z.string().optional(),
29
  examType: z.string().optional(),
30
  page: z.coerce.number().optional(),
 
31
  }).parse,
32
  beforeLoad: async () => {
33
  const session = await authClient.getSession();
@@ -59,6 +61,7 @@ function PackagesComponent() {
59
  const searchText = search.search ?? "";
60
  const examType = search.examType ?? "";
61
  const page = search.page ?? 1;
 
62
  const limit = 12;
63
 
64
  const allQuery = useQuery(
@@ -74,6 +77,10 @@ function PackagesComponent() {
74
  ),
75
  );
76
 
 
 
 
 
77
  const mineQuery = useQuery(
78
  trpc.package.myPackages.queryOptions(
79
  {
@@ -81,6 +88,7 @@ function PackagesComponent() {
81
  examTypeId: examType || undefined,
82
  limit,
83
  offset: (page - 1) * limit,
 
84
  },
85
  { enabled: tab === "mine" },
86
  ),
@@ -108,9 +116,18 @@ function PackagesComponent() {
108
  query.refetch();
109
  setBulkMode(false);
110
  setSelectedIds(new Set());
111
- toast.success(`${data.updated} paket berhasil dipublikasikan`);
 
 
 
 
 
 
 
 
 
 
112
  },
113
- onError: (err: any) => toast.error("Gagal mempublikasikan", { description: err.message }),
114
  });
115
 
116
  // ── Bulk select ──
@@ -138,6 +155,24 @@ function PackagesComponent() {
138
  navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
139
  };
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  const setSearch = (value: string) => {
142
  navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
143
  };
@@ -146,6 +181,10 @@ function PackagesComponent() {
146
  navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
147
  };
148
 
 
 
 
 
149
  const setPage = (newPage: number) => {
150
  navigate({ search: (prev) => ({ ...prev, page: newPage }) });
151
  };
@@ -225,6 +264,22 @@ function PackagesComponent() {
225
  </SelectGroup>
226
  </SelectContent>
227
  </Select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  </div>
229
 
230
  {/* Bulk toolbar */}
@@ -281,6 +336,17 @@ function PackagesComponent() {
281
  </div>
282
  )}
283
 
 
 
 
 
 
 
 
 
 
 
 
284
  {/* Results */}
285
  {query.isLoading ? (
286
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -324,7 +390,9 @@ function PackagesComponent() {
324
  className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
325
  bulkMode && isSelected
326
  ? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
327
- : "border-[var(--oat-border)]"
 
 
328
  }`}
329
  >
330
  <CardContent className="p-5 flex flex-col h-full">
@@ -354,12 +422,13 @@ function PackagesComponent() {
354
  </span>
355
  {isOwner && !bulkMode && (
356
  <span
357
- className={`px-2 py-1 rounded-full text-[10px] font-semibold ${
358
  pkg.isPublic
359
  ? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
360
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
361
  }`}
362
  >
 
363
  {pkg.isPublic ? "Publik" : "Privat"}
364
  </span>
365
  )}
@@ -412,12 +481,14 @@ function PackagesComponent() {
412
  <button
413
  onClick={() => togglePublic(pkg.id, pkg.isPublic)}
414
  disabled={updateMutation.isPending}
415
- className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors ${
 
416
  pkg.isPublic
417
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
418
- : "bg-[var(--oat-light)] text-[var(--warm-charcoal)]"
419
  }`}
420
  >
 
421
  {pkg.isPublic ? "Publik" : "Privat"}
422
  </button>
423
  {pkg.isPublic && (
@@ -508,3 +579,18 @@ const packagesPageSteps: Step[] = [
508
  spotlightPadding: 8,
509
  },
510
  ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "react";
2
  import { useQuery, useMutation } from "@tanstack/react-query";
3
  import { createFileRoute, redirect, Link, useNavigate } from "@tanstack/react-router";
4
  import { z } from "zod";
 
17
  } from "@labas/ui/components/select";
18
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
19
  import { GettingStartedCard } from "@/components/GettingStartedCard";
20
+ import { CalloutCard } from "@/components/bank/CalloutCard";
21
  import { PageTour, TourHelpButton } from "@/components/TourGuide";
22
  import type { Step } from "react-joyride";
23
  import { toast } from "sonner";
 
29
  search: z.string().optional(),
30
  examType: z.string().optional(),
31
  page: z.coerce.number().optional(),
32
+ visibility: z.enum(["all", "private", "public"]).optional(),
33
  }).parse,
34
  beforeLoad: async () => {
35
  const session = await authClient.getSession();
 
61
  const searchText = search.search ?? "";
62
  const examType = search.examType ?? "";
63
  const page = search.page ?? 1;
64
+ const visibilityFilter = search.visibility ?? "all";
65
  const limit = 12;
66
 
67
  const allQuery = useQuery(
 
77
  ),
78
  );
79
 
80
+ const visibilityFilterParam = tab === "mine" && visibilityFilter !== "all"
81
+ ? { isPublic: visibilityFilter === "public" }
82
+ : {};
83
+
84
  const mineQuery = useQuery(
85
  trpc.package.myPackages.queryOptions(
86
  {
 
88
  examTypeId: examType || undefined,
89
  limit,
90
  offset: (page - 1) * limit,
91
+ ...visibilityFilterParam,
92
  },
93
  { enabled: tab === "mine" },
94
  ),
 
116
  query.refetch();
117
  setBulkMode(false);
118
  setSelectedIds(new Set());
119
+ if (data.skipped > 0) {
120
+ toast.success(
121
+ `${data.updated} paket dipublikasikan, ${data.skipped} dilewati`,
122
+ { description: "Beberapa paket bukan milikmu atau sudah tidak tersedia." },
123
+ );
124
+ } else {
125
+ toast.success(`${data.updated} paket berhasil dipublikasikan`);
126
+ }
127
+ },
128
+ onError: (err: any) => {
129
+ toast.error("Gagal mempublikasikan. Coba refresh dan pilih ulang paket.", { description: err.message });
130
  },
 
131
  });
132
 
133
  // ── Bulk select ──
 
155
  navigate({ search: { tab: newTab, search: "", examType: "", page: 1 } });
156
  };
157
 
158
+ // ── Private callout state ──
159
+ const [calloutDismissed, setCalloutDismissed] = useState(
160
+ typeof window !== "undefined" && localStorage.getItem("labas-packages-private-callout-dismissed") === "true",
161
+ );
162
+ const privatePackages = packages.filter(
163
+ (p: any) => !p.isPublic && p.creatorUserId === userId,
164
+ );
165
+
166
+ const handleDismissCallout = () => {
167
+ localStorage.setItem("labas-packages-private-callout-dismissed", "true");
168
+ setCalloutDismissed(true);
169
+ };
170
+
171
+ const handlePublishAllPrivate = () => {
172
+ const ids = privatePackages.map((p: any) => p.id);
173
+ if (ids.length > 0) bulkPublish.mutate({ ids });
174
+ };
175
+
176
  const setSearch = (value: string) => {
177
  navigate({ search: (prev) => ({ ...prev, search: value, page: 1 }) });
178
  };
 
181
  navigate({ search: (prev) => ({ ...prev, examType: value, page: 1 }) });
182
  };
183
 
184
+ const setVisibility = (value: "all" | "private" | "public") => {
185
+ navigate({ search: (prev) => ({ ...prev, visibility: value === "all" ? undefined : value, page: 1 }) });
186
+ };
187
+
188
  const setPage = (newPage: number) => {
189
  navigate({ search: (prev) => ({ ...prev, page: newPage }) });
190
  };
 
264
  </SelectGroup>
265
  </SelectContent>
266
  </Select>
267
+ {tab === "mine" && (
268
+ <div className="flex gap-2">
269
+ <VisChip active={visibilityFilter === "all"} onClick={() => setVisibility("all")}>
270
+ <MaterialIcon name="visibility" className="text-xs" />
271
+ Semua
272
+ </VisChip>
273
+ <VisChip active={visibilityFilter === "private"} onClick={() => setVisibility("private")}>
274
+ <MaterialIcon name="lock" className="text-xs" />
275
+ Privat
276
+ </VisChip>
277
+ <VisChip active={visibilityFilter === "public"} onClick={() => setVisibility("public")}>
278
+ <MaterialIcon name="public" className="text-xs" />
279
+ Publik
280
+ </VisChip>
281
+ </div>
282
+ )}
283
  </div>
284
 
285
  {/* Bulk toolbar */}
 
336
  </div>
337
  )}
338
 
339
+ {/* Private package callout */}
340
+ {tab === "mine" && privatePackages.length > 0 && !calloutDismissed && (
341
+ <div className="mb-6">
342
+ <CalloutCard
343
+ privateCount={privatePackages.length}
344
+ onPublishAll={handlePublishAllPrivate}
345
+ onDismiss={handleDismissCallout}
346
+ />
347
+ </div>
348
+ )}
349
+
350
  {/* Results */}
351
  {query.isLoading ? (
352
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
 
390
  className={`clay-shadow clay-hover bg-[var(--pure-white)] border-2 rounded-[var(--radius-xl)] h-full flex flex-col ${
391
  bulkMode && isSelected
392
  ? "border-[var(--matcha-600)] ring-2 ring-[var(--matcha-400)]"
393
+ : isOwner && !pkg.isPublic && !bulkMode
394
+ ? "border-[var(--oat-border)] border-l-[var(--warm-charcoal)] border-l-4"
395
+ : "border-[var(--oat-border)]"
396
  }`}
397
  >
398
  <CardContent className="p-5 flex flex-col h-full">
 
422
  </span>
423
  {isOwner && !bulkMode && (
424
  <span
425
+ className={`px-2 py-1 rounded-full text-[10px] font-semibold flex items-center gap-1 ${
426
  pkg.isPublic
427
  ? "bg-[var(--slushie-500)]/20 text-[var(--slushie-800)]"
428
+ : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
429
  }`}
430
  >
431
+ {!pkg.isPublic && <MaterialIcon name="lock" className="text-[10px]" />}
432
  {pkg.isPublic ? "Publik" : "Privat"}
433
  </span>
434
  )}
 
481
  <button
482
  onClick={() => togglePublic(pkg.id, pkg.isPublic)}
483
  disabled={updateMutation.isPending}
484
+ title={pkg.isPublic ? "Klik untuk jadikan privat" : "Klik untuk jadikan publik"}
485
+ className={`text-xs font-semibold px-3 py-1.5 rounded-full transition-colors flex items-center gap-1 cursor-pointer ${
486
  pkg.isPublic
487
  ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
488
+ : "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)]"
489
  }`}
490
  >
491
+ {!pkg.isPublic && <MaterialIcon name="lock" className="text-xs" />}
492
  {pkg.isPublic ? "Publik" : "Privat"}
493
  </button>
494
  {pkg.isPublic && (
 
579
  spotlightPadding: 8,
580
  },
581
  ];
582
+
583
+ function VisChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
584
+ return (
585
+ <button
586
+ onClick={onClick}
587
+ className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all flex items-center gap-1 cursor-pointer ${
588
+ active
589
+ ? "bg-[var(--clay-black)] text-[var(--pure-white)] clay-shadow"
590
+ : "bg-[var(--pure-white)] text-[var(--warm-charcoal)] border-2 border-[var(--oat-border)] hover:bg-[var(--oat-light)]"
591
+ }`}
592
+ >
593
+ {children}
594
+ </button>
595
+ );
596
+ }
packages/api/src/routers/package.ts CHANGED
@@ -85,6 +85,7 @@ export const packageRouter = router({
85
  .object({
86
  search: z.string().optional(),
87
  examTypeId: z.string().optional(),
 
88
  ...paginationSchema.shape,
89
  })
90
  .optional(),
@@ -102,6 +103,9 @@ export const packageRouter = router({
102
  if (input?.examTypeId) {
103
  conditions.push(eq(testPackage.examTypeId, input.examTypeId));
104
  }
 
 
 
105
 
106
  const where = and(...conditions);
107
 
@@ -311,16 +315,20 @@ export const packageRouter = router({
311
  .from(testPackage)
312
  .where(inArray(testPackage.id, input.ids));
313
 
314
- for (const row of rows) {
315
- assertOwnership(row, ctx.session.user.id, "Package");
 
 
 
316
  }
317
 
 
318
  await db
319
  .update(testPackage)
320
  .set({ isPublic: true })
321
- .where(inArray(testPackage.id, input.ids));
322
 
323
- return { success: true, updated: rows.length };
324
  }),
325
 
326
  // ── Section Management ───────────────────────────────────
 
85
  .object({
86
  search: z.string().optional(),
87
  examTypeId: z.string().optional(),
88
+ isPublic: z.boolean().optional(),
89
  ...paginationSchema.shape,
90
  })
91
  .optional(),
 
103
  if (input?.examTypeId) {
104
  conditions.push(eq(testPackage.examTypeId, input.examTypeId));
105
  }
106
+ if (input?.isPublic !== undefined) {
107
+ conditions.push(eq(testPackage.isPublic, input.isPublic));
108
+ }
109
 
110
  const where = and(...conditions);
111
 
 
315
  .from(testPackage)
316
  .where(inArray(testPackage.id, input.ids));
317
 
318
+ const ownRows = rows.filter((r) => r.creatorUserId === ctx.session.user.id);
319
+ const skipped = rows.length - ownRows.length;
320
+
321
+ if (ownRows.length === 0) {
322
+ throwNotFound("Package");
323
  }
324
 
325
+ const ownIds = ownRows.map((r) => r.id);
326
  await db
327
  .update(testPackage)
328
  .set({ isPublic: true })
329
+ .where(inArray(testPackage.id, ownIds));
330
 
331
+ return { success: true, updated: ownRows.length, skipped };
332
  }),
333
 
334
  // ── Section Management ───────────────────────────────────