@allbluecn/web-app 0.4.22 → 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.
- package/package.json +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +124 -51
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +14 -3
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- package/src/components/story/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +30 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/components/story/relations/tab.tsx +0 -212
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
RefreshCw,
|
|
13
13
|
Trash2,
|
|
14
14
|
X,
|
|
15
|
-
UserPlus,
|
|
16
15
|
} from "lucide-react"
|
|
17
16
|
import { useMemo, useEffect, useState } from "react"
|
|
18
17
|
import { useTranslation } from "react-i18next"
|
|
@@ -82,6 +81,8 @@ export interface TeamInvitation {
|
|
|
82
81
|
expiresAt?: Date
|
|
83
82
|
createdAt: Date
|
|
84
83
|
acceptedAt?: Date
|
|
84
|
+
joinRequestId?: string
|
|
85
|
+
joinRequestStatus?: "pending" | "approved" | "rejected" | "invalidated"
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export interface TeamJoinRequestItem {
|
|
@@ -109,6 +110,8 @@ export interface TeamInvitationsProps {
|
|
|
109
110
|
onApproveLink?: (invitationId: string, role: "admin" | "member" | "viewer") => Promise<void>
|
|
110
111
|
onApproveRequest?: (requestId: string, role: "admin" | "member" | "viewer") => Promise<void>
|
|
111
112
|
onRejectRequest?: (requestId: string) => Promise<void>
|
|
113
|
+
onDeleteInvitation?: (invitationId: string) => Promise<void>
|
|
114
|
+
onDeleteRequest?: (requestId: string) => Promise<void>
|
|
112
115
|
onClearExpired?: () => Promise<void>
|
|
113
116
|
className?: string
|
|
114
117
|
allowDirectLinks?: boolean
|
|
@@ -135,6 +138,18 @@ function getInvitationBadge(
|
|
|
135
138
|
invitation: TeamInvitation,
|
|
136
139
|
t: (key: string) => string,
|
|
137
140
|
) {
|
|
141
|
+
if (invitation.joinRequestId) {
|
|
142
|
+
switch (invitation.joinRequestStatus) {
|
|
143
|
+
case "pending":
|
|
144
|
+
return <Badge variant="default">{t("team.status.pendingApproval")}</Badge>
|
|
145
|
+
case "approved":
|
|
146
|
+
return <Badge variant="default">{t("team.joinRequests.requestApproved")}</Badge>
|
|
147
|
+
case "rejected":
|
|
148
|
+
return <Badge variant="destructive">{t("team.joinRequests.requestRejected")}</Badge>
|
|
149
|
+
case "invalidated":
|
|
150
|
+
return <Badge variant="outline">{t("team.joinRequests.requestInvalidated")}</Badge>
|
|
151
|
+
}
|
|
152
|
+
}
|
|
138
153
|
switch (invitation.status) {
|
|
139
154
|
case "pending":
|
|
140
155
|
return <Badge variant="secondary">{t("team.status.pending")}</Badge>
|
|
@@ -169,6 +184,8 @@ export default function TeamInvitations({
|
|
|
169
184
|
onApproveLink,
|
|
170
185
|
onApproveRequest,
|
|
171
186
|
onRejectRequest,
|
|
187
|
+
onDeleteInvitation,
|
|
188
|
+
onDeleteRequest,
|
|
172
189
|
onClearExpired,
|
|
173
190
|
className,
|
|
174
191
|
allowDirectLinks = true,
|
|
@@ -213,11 +230,32 @@ export default function TeamInvitations({
|
|
|
213
230
|
(!!activeLinkInvitation.expiresAt && new Date(activeLinkInvitation.expiresAt) < new Date())
|
|
214
231
|
)
|
|
215
232
|
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
233
|
+
const joinRequestInvitations: TeamInvitation[] = joinRequests.map((req) => ({
|
|
234
|
+
id: `jr-${req.id}`,
|
|
235
|
+
channel: "link",
|
|
236
|
+
status:
|
|
237
|
+
req.status === "pending"
|
|
238
|
+
? "pending_approval"
|
|
239
|
+
: req.status === "approved"
|
|
240
|
+
? "accepted"
|
|
241
|
+
: "revoked",
|
|
242
|
+
joinRequestId: req.id,
|
|
243
|
+
joinRequestStatus: req.status,
|
|
244
|
+
applicantEmail: req.email,
|
|
245
|
+
role:
|
|
246
|
+
req.status === "approved"
|
|
247
|
+
? req.seatType === "guest"
|
|
248
|
+
? "viewer"
|
|
249
|
+
: "member"
|
|
250
|
+
: undefined,
|
|
251
|
+
invitedBy: { name: "", email: "" },
|
|
252
|
+
createdAt: req.createdAt,
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
const historyInvitations = [
|
|
256
|
+
...invitations.filter((inv) => inv.channel === "email"),
|
|
257
|
+
...joinRequestInvitations,
|
|
258
|
+
].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
|
221
259
|
|
|
222
260
|
useEffect(() => {
|
|
223
261
|
if (freshLink && activeLinkInvitation?.id === freshLink.id && activeLinkInvitation !== freshLink) {
|
|
@@ -228,12 +266,14 @@ export default function TeamInvitations({
|
|
|
228
266
|
const pendingInvitations = invitations.filter(
|
|
229
267
|
(i) => i.status === "pending" || i.status === "pending_approval",
|
|
230
268
|
)
|
|
231
|
-
const approvalPendingInvitations =
|
|
232
|
-
(i) => i.status === "pending_approval",
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
269
|
+
const approvalPendingInvitations = [
|
|
270
|
+
...invitations.filter((i) => i.status === "pending_approval"),
|
|
271
|
+
...joinRequests.filter((r) => r.status === "pending"),
|
|
272
|
+
]
|
|
273
|
+
const acceptedInvitations = [
|
|
274
|
+
...invitations.filter((i) => i.status === "accepted"),
|
|
275
|
+
...joinRequests.filter((r) => r.status === "approved"),
|
|
276
|
+
]
|
|
237
277
|
|
|
238
278
|
const handleRefreshLink = async () => {
|
|
239
279
|
setLinkRefreshing(true)
|
|
@@ -317,12 +357,16 @@ export default function TeamInvitations({
|
|
|
317
357
|
}
|
|
318
358
|
|
|
319
359
|
const handleApprove = async (
|
|
320
|
-
|
|
360
|
+
invitation: TeamInvitation,
|
|
321
361
|
role: "admin" | "member" | "viewer",
|
|
322
362
|
) => {
|
|
323
|
-
setActionLoading(
|
|
363
|
+
setActionLoading(invitation.id)
|
|
324
364
|
try {
|
|
325
|
-
|
|
365
|
+
if (invitation.joinRequestId) {
|
|
366
|
+
await onApproveRequest?.(invitation.joinRequestId, role)
|
|
367
|
+
} else {
|
|
368
|
+
await onApproveLink?.(invitation.id, role)
|
|
369
|
+
}
|
|
326
370
|
} finally {
|
|
327
371
|
setActionLoading(null)
|
|
328
372
|
}
|
|
@@ -476,98 +520,6 @@ export default function TeamInvitations({
|
|
|
476
520
|
</div>
|
|
477
521
|
)}
|
|
478
522
|
|
|
479
|
-
{joinRequests.length > 0 && (
|
|
480
|
-
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4">
|
|
481
|
-
<div className="flex items-center gap-2">
|
|
482
|
-
<UserPlus className="size-4" />
|
|
483
|
-
<h4 className="font-medium text-sm">{t("team.joinRequests.title")}</h4>
|
|
484
|
-
<span className="text-muted-foreground text-xs">
|
|
485
|
-
{t("team.joinRequests.description")}
|
|
486
|
-
</span>
|
|
487
|
-
</div>
|
|
488
|
-
<div className="flex flex-col gap-3">
|
|
489
|
-
{joinRequests.map((req) => (
|
|
490
|
-
<div className="flex flex-col gap-2 rounded-lg border bg-card p-3" key={req.id}>
|
|
491
|
-
<div className="flex items-center justify-between gap-3">
|
|
492
|
-
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
493
|
-
<span className="font-medium text-sm">{req.applicantName || req.email}</span>
|
|
494
|
-
<Badge variant="outline" className="text-xs">
|
|
495
|
-
{t("team.joinRequests.sourceLink")}
|
|
496
|
-
</Badge>
|
|
497
|
-
{req.status === "pending" && (
|
|
498
|
-
<Badge variant="default">{t("team.status.pendingApproval")}</Badge>
|
|
499
|
-
)}
|
|
500
|
-
{req.status === "approved" && (
|
|
501
|
-
<Badge variant="default">{t("team.joinRequests.requestApproved")}</Badge>
|
|
502
|
-
)}
|
|
503
|
-
{req.status === "rejected" && (
|
|
504
|
-
<Badge variant="destructive">{t("team.joinRequests.requestRejected")}</Badge>
|
|
505
|
-
)}
|
|
506
|
-
{req.status === "invalidated" && (
|
|
507
|
-
<Badge variant="outline">{t("team.joinRequests.requestInvalidated")}</Badge>
|
|
508
|
-
)}
|
|
509
|
-
</div>
|
|
510
|
-
{req.status === "pending" && onApproveRequest && (
|
|
511
|
-
<div className="flex items-center gap-2">
|
|
512
|
-
<Select
|
|
513
|
-
value={approveRoles[req.id] ?? "member"}
|
|
514
|
-
onValueChange={(value: "admin" | "member" | "viewer") =>
|
|
515
|
-
setApproveRoles((prev) => ({ ...prev, [req.id]: value }))
|
|
516
|
-
}
|
|
517
|
-
>
|
|
518
|
-
<SelectTrigger className="h-8 w-24" size="sm">
|
|
519
|
-
<SelectValue />
|
|
520
|
-
</SelectTrigger>
|
|
521
|
-
<SelectContent>
|
|
522
|
-
<SelectItem value="member">{t("team.role.member")}</SelectItem>
|
|
523
|
-
<SelectItem value="admin">{t("team.role.admin")}</SelectItem>
|
|
524
|
-
<SelectItem value="viewer">{t("team.role.viewer")}</SelectItem>
|
|
525
|
-
</SelectContent>
|
|
526
|
-
</Select>
|
|
527
|
-
<Button
|
|
528
|
-
disabled={actionLoading === req.id}
|
|
529
|
-
onClick={() =>
|
|
530
|
-
handleAction(async () => {
|
|
531
|
-
await onApproveRequest(req.id, approveRoles[req.id] ?? "member")
|
|
532
|
-
}, req.id)
|
|
533
|
-
}
|
|
534
|
-
size="sm"
|
|
535
|
-
type="button"
|
|
536
|
-
>
|
|
537
|
-
{actionLoading === req.id ? (
|
|
538
|
-
<Loader2 className="size-4 animate-spin" />
|
|
539
|
-
) : (
|
|
540
|
-
<Check className="size-4" />
|
|
541
|
-
)}
|
|
542
|
-
{t("team.invite.approve")}
|
|
543
|
-
</Button>
|
|
544
|
-
<Button
|
|
545
|
-
disabled={actionLoading === req.id}
|
|
546
|
-
onClick={() =>
|
|
547
|
-
handleAction(async () => {
|
|
548
|
-
await onRejectRequest?.(req.id)
|
|
549
|
-
}, req.id)
|
|
550
|
-
}
|
|
551
|
-
size="sm"
|
|
552
|
-
type="button"
|
|
553
|
-
variant="ghost"
|
|
554
|
-
>
|
|
555
|
-
<X className="size-4" />
|
|
556
|
-
{t("team.joinRequests.reject")}
|
|
557
|
-
</Button>
|
|
558
|
-
</div>
|
|
559
|
-
)}
|
|
560
|
-
</div>
|
|
561
|
-
<div className="text-muted-foreground text-xs">
|
|
562
|
-
{t("team.joinRequests.appliedAt")}{" "}
|
|
563
|
-
{formatDate(req.createdAt, timezone, "MMM d, yyyy")}
|
|
564
|
-
</div>
|
|
565
|
-
</div>
|
|
566
|
-
))}
|
|
567
|
-
</div>
|
|
568
|
-
</div>
|
|
569
|
-
)}
|
|
570
|
-
|
|
571
523
|
<div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-4">
|
|
572
524
|
<div className="flex items-center gap-2">
|
|
573
525
|
<Mail className="size-4" />
|
|
@@ -753,15 +705,26 @@ export default function TeamInvitations({
|
|
|
753
705
|
)}
|
|
754
706
|
</div>
|
|
755
707
|
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
708
|
+
{invitation.joinRequestId ? (
|
|
709
|
+
<span>
|
|
710
|
+
{t("team.joinRequests.appliedAt")}{" "}
|
|
711
|
+
{formatDate(
|
|
712
|
+
invitation.createdAt,
|
|
713
|
+
timezone,
|
|
714
|
+
"MMM d, yyyy",
|
|
715
|
+
)}
|
|
716
|
+
</span>
|
|
717
|
+
) : (
|
|
718
|
+
<span>
|
|
719
|
+
{t("team.invite.invitedAt")}{" "}
|
|
720
|
+
{invitation.invitedBy.name}{" "}
|
|
721
|
+
{formatDate(
|
|
722
|
+
invitation.createdAt,
|
|
723
|
+
timezone,
|
|
724
|
+
"MMM d, yyyy",
|
|
725
|
+
)}
|
|
726
|
+
</span>
|
|
727
|
+
)}
|
|
765
728
|
{invitation.expiresAt && (
|
|
766
729
|
<>
|
|
767
730
|
<span aria-hidden="true">•</span>
|
|
@@ -815,7 +778,7 @@ export default function TeamInvitations({
|
|
|
815
778
|
<Button
|
|
816
779
|
disabled={actionLoading === invitation.id}
|
|
817
780
|
onClick={() =>
|
|
818
|
-
handleApprove(invitation
|
|
781
|
+
handleApprove(invitation, approveRoles[invitation.id] ?? "member")
|
|
819
782
|
}
|
|
820
783
|
size="sm"
|
|
821
784
|
type="button"
|
|
@@ -846,22 +809,39 @@ export default function TeamInvitations({
|
|
|
846
809
|
<DropdownMenuItem
|
|
847
810
|
onSelect={() =>
|
|
848
811
|
handleAction(
|
|
849
|
-
() =>
|
|
812
|
+
() =>
|
|
813
|
+
invitation.joinRequestId
|
|
814
|
+
? onRejectRequest!(invitation.joinRequestId!)
|
|
815
|
+
: onRevoke!(invitation.id),
|
|
850
816
|
invitation.id,
|
|
851
817
|
)
|
|
852
818
|
}
|
|
853
819
|
variant="destructive"
|
|
854
820
|
>
|
|
855
|
-
|
|
856
|
-
|
|
821
|
+
{invitation.joinRequestId ? (
|
|
822
|
+
<X className="size-4" />
|
|
823
|
+
) : (
|
|
824
|
+
<Trash2 className="size-4" />
|
|
825
|
+
)}
|
|
826
|
+
{invitation.joinRequestId
|
|
827
|
+
? t("team.joinRequests.reject")
|
|
828
|
+
: t("team.revoke")}
|
|
857
829
|
</DropdownMenuItem>
|
|
858
830
|
</DropdownMenuContent>
|
|
859
831
|
</DropdownMenu>
|
|
860
832
|
</div>
|
|
861
833
|
)}
|
|
862
|
-
{!(invitation.status === "pending_approval" && onApproveLink) &&
|
|
863
|
-
|
|
864
|
-
const
|
|
834
|
+
{!(invitation.status === "pending_approval" && onApproveLink) &&
|
|
835
|
+
(() => {
|
|
836
|
+
const canResend = !invitation.joinRequestId && ["pending", "expired"].includes(invitation.status) && onResend
|
|
837
|
+
const canRevoke =
|
|
838
|
+
!invitation.joinRequestId &&
|
|
839
|
+
["pending", "pending_approval"].includes(invitation.status) &&
|
|
840
|
+
onRevoke
|
|
841
|
+
const canDelete = invitation.joinRequestId
|
|
842
|
+
? !!onDeleteRequest
|
|
843
|
+
: !!onDeleteInvitation
|
|
844
|
+
if (!canResend && !canRevoke && !canDelete) return null
|
|
865
845
|
return (
|
|
866
846
|
<DropdownMenu>
|
|
867
847
|
<DropdownMenuTrigger asChild>
|
|
@@ -913,21 +893,32 @@ export default function TeamInvitations({
|
|
|
913
893
|
</DropdownMenuItem>
|
|
914
894
|
</>
|
|
915
895
|
)}
|
|
896
|
+
{canDelete && (
|
|
897
|
+
<>
|
|
898
|
+
{(canResend || canRevoke) && <DropdownMenuSeparator />}
|
|
899
|
+
<DropdownMenuItem
|
|
900
|
+
onSelect={() =>
|
|
901
|
+
handleAction(
|
|
902
|
+
() =>
|
|
903
|
+
invitation.joinRequestId
|
|
904
|
+
? onDeleteRequest!(invitation.joinRequestId!)
|
|
905
|
+
: onDeleteInvitation!(invitation.id),
|
|
906
|
+
invitation.id,
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
variant="destructive"
|
|
910
|
+
>
|
|
911
|
+
<Trash2 className="size-4" />
|
|
912
|
+
{t("team.invite.deleteRecord")}
|
|
913
|
+
</DropdownMenuItem>
|
|
914
|
+
</>
|
|
915
|
+
)}
|
|
916
916
|
</DropdownMenuContent>
|
|
917
917
|
</DropdownMenu>
|
|
918
918
|
)
|
|
919
919
|
})()}
|
|
920
920
|
</div>
|
|
921
|
-
|
|
922
|
-
(invitation.status === "expired" ||
|
|
923
|
-
invitation.status === "revoked") && (
|
|
924
|
-
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
|
925
|
-
<code className="wrap-break-word truncate text-wrap text-xs">
|
|
926
|
-
{invitation.link}
|
|
927
|
-
</code>
|
|
928
|
-
</div>
|
|
929
|
-
)}
|
|
930
|
-
{invitation.status === "pending_approval" && onApproveLink && (
|
|
921
|
+
{invitation.status === "pending_approval" && onApproveLink && (
|
|
931
922
|
<div className="xl:hidden flex justify-end items-center gap-2">
|
|
932
923
|
<Select
|
|
933
924
|
value={approveRoles[invitation.id] ?? "member"}
|
|
@@ -950,60 +941,69 @@ export default function TeamInvitations({
|
|
|
950
941
|
</SelectItem>
|
|
951
942
|
</SelectContent>
|
|
952
943
|
</Select>
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
944
|
+
<Button
|
|
945
|
+
disabled={actionLoading === invitation.id}
|
|
946
|
+
onClick={() =>
|
|
947
|
+
handleApprove(invitation, approveRoles[invitation.id] ?? "member")
|
|
948
|
+
}
|
|
949
|
+
size="sm"
|
|
950
|
+
type="button"
|
|
951
|
+
>
|
|
952
|
+
{actionLoading === invitation.id ? (
|
|
953
|
+
<Loader2 className="size-4 animate-spin" />
|
|
954
|
+
) : (
|
|
955
|
+
<Check className="size-4" />
|
|
956
|
+
)}
|
|
957
|
+
{t("team.invite.approve")}
|
|
958
|
+
</Button>
|
|
959
|
+
<DropdownMenu>
|
|
960
|
+
<DropdownMenuTrigger asChild>
|
|
961
|
+
<Button
|
|
962
|
+
aria-label="More options"
|
|
963
|
+
size="icon"
|
|
964
|
+
type="button"
|
|
965
|
+
variant="ghost"
|
|
966
|
+
>
|
|
967
|
+
<X className="size-4" />
|
|
968
|
+
</Button>
|
|
969
|
+
</DropdownMenuTrigger>
|
|
970
|
+
<DropdownMenuContent
|
|
971
|
+
align="end"
|
|
972
|
+
collisionPadding={8}
|
|
973
|
+
sideOffset={4}
|
|
974
|
+
>
|
|
975
|
+
<DropdownMenuItem
|
|
976
|
+
onSelect={() =>
|
|
977
|
+
handleAction(
|
|
978
|
+
() =>
|
|
979
|
+
invitation.joinRequestId
|
|
980
|
+
? onRejectRequest!(invitation.joinRequestId!)
|
|
981
|
+
: onRevoke!(invitation.id),
|
|
982
|
+
invitation.id,
|
|
983
|
+
)
|
|
984
|
+
}
|
|
985
|
+
variant="destructive"
|
|
986
|
+
>
|
|
987
|
+
{invitation.joinRequestId ? (
|
|
988
|
+
<X className="size-4" />
|
|
989
|
+
) : (
|
|
990
|
+
<Trash2 className="size-4" />
|
|
991
|
+
)}
|
|
992
|
+
{invitation.joinRequestId
|
|
993
|
+
? t("team.joinRequests.reject")
|
|
994
|
+
: t("team.revoke")}
|
|
995
|
+
</DropdownMenuItem>
|
|
996
|
+
</DropdownMenuContent>
|
|
997
|
+
</DropdownMenu>
|
|
998
|
+
</div>
|
|
999
|
+
)}
|
|
1000
1000
|
</div>
|
|
1001
1001
|
))}
|
|
1002
1002
|
</div>
|
|
1003
1003
|
</>
|
|
1004
1004
|
)}
|
|
1005
1005
|
|
|
1006
|
-
{
|
|
1006
|
+
{historyInvitations.length === 0 && (
|
|
1007
1007
|
<Empty>
|
|
1008
1008
|
<EmptyHeader>
|
|
1009
1009
|
<EmptyMedia variant="icon">
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 波形弹跳加载动画(纯 CSS keyframes 版)。
|
|
22
|
+
*
|
|
23
|
+
* 性能关键:所有动画只用 transform / opacity(合成器线程驱动,主线程繁忙不掉帧),
|
|
24
|
+
* 禁用 height/background-color 等布局属性。柱子视觉高度 = scaleY(最大高),明暗 =
|
|
25
|
+
* 柱色 opacity(深色柱叠浅底 ≈ 原版 zinc-200→zinc-800 插值)。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const NUM_BARS = 15;
|
|
29
|
+
const BAR_W = 12;
|
|
30
|
+
const BAR_TOTAL_W = BAR_W + 8; // 12 + 8 gap
|
|
31
|
+
const NUM_FRAMES = 60; // 关键帧间由浏览器逐帧插值,60 个定义点足够平滑
|
|
32
|
+
const B = 4;
|
|
33
|
+
|
|
34
|
+
type WaveTheme = "light" | "dark";
|
|
35
|
+
const MAX_BOUNCE = 60;
|
|
36
|
+
const BASE_H = 16;
|
|
37
|
+
const WAVE_PEAK = 48;
|
|
38
|
+
const MAX_H = BASE_H + WAVE_PEAK; // 64,柱子布局高度(transform 不影响布局)
|
|
39
|
+
const MIN_OPACITY = 0.125; // opacity 下限 ≈ 原版最浅色 zinc-200 叠白底的等效值
|
|
40
|
+
const DURATION_S = 4;
|
|
41
|
+
|
|
42
|
+
interface WaveKeyframes {
|
|
43
|
+
css: string;
|
|
44
|
+
barNames: string[];
|
|
45
|
+
ballName: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 物理曲线 → scaleY/opacity/translate keyframes(合成器友好属性) */
|
|
49
|
+
function buildKeyframes(theme: WaveTheme): WaveKeyframes {
|
|
50
|
+
const suffix = theme === "dark" ? "d" : "l";
|
|
51
|
+
const barNames = Array.from({ length: NUM_BARS }, (_, i) => `wpb-h-${suffix}-${i}`);
|
|
52
|
+
const ballName = `wpb-ball-${suffix}`;
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < NUM_BARS; i++) {
|
|
56
|
+
const parts: string[] = [];
|
|
57
|
+
for (let k = 0; k < NUM_FRAMES; k++) {
|
|
58
|
+
const t = k / (NUM_FRAMES - 1);
|
|
59
|
+
const pct = ((k / (NUM_FRAMES - 1)) * 100).toFixed(4);
|
|
60
|
+
const xFrac = t < 0.5 ? t / 0.5 : (1 - t) / 0.5;
|
|
61
|
+
const ballIdx = xFrac * (NUM_BARS - 1);
|
|
62
|
+
const f = (xFrac * B) % 1;
|
|
63
|
+
const bounceH = 4 * f * (1 - f);
|
|
64
|
+
const heightFactor = Math.max(0, 1 - bounceH * 2);
|
|
65
|
+
const dist = Math.abs(i - ballIdx);
|
|
66
|
+
const waveVal = dist < 3 ? Math.cos((dist / 3) * (Math.PI / 2)) : 0;
|
|
67
|
+
let indent = 0;
|
|
68
|
+
if (dist < 1.5) {
|
|
69
|
+
indent = Math.cos((dist / 1.5) * (Math.PI / 2)) * heightFactor * 20;
|
|
70
|
+
}
|
|
71
|
+
const barH = Math.max(4, BASE_H + waveVal * WAVE_PEAK - indent);
|
|
72
|
+
const scaleY = barH / MAX_H;
|
|
73
|
+
// 深色柱 + opacity:亮起程度等价原版 zinc-200→zinc-800 颜色插值
|
|
74
|
+
const opacity = MIN_OPACITY + waveVal * (1 - MIN_OPACITY);
|
|
75
|
+
parts.push(`${pct}%{transform:scaleY(${scaleY.toFixed(4)});opacity:${opacity.toFixed(3)}}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push(`@keyframes ${barNames[i]}{${parts.join("")}}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ballParts: string[] = [];
|
|
81
|
+
for (let k = 0; k < NUM_FRAMES; k++) {
|
|
82
|
+
const t = k / (NUM_FRAMES - 1);
|
|
83
|
+
const pct = ((k / (NUM_FRAMES - 1)) * 100).toFixed(4);
|
|
84
|
+
const xFrac = t < 0.5 ? t / 0.5 : (1 - t) / 0.5;
|
|
85
|
+
const ballIdx = xFrac * (NUM_BARS - 1);
|
|
86
|
+
let bounceF = (xFrac * B) % 1;
|
|
87
|
+
if (xFrac === 1 || xFrac === 0) bounceF = 0;
|
|
88
|
+
const bounceH = 4 * bounceF * (1 - bounceF);
|
|
89
|
+
const heightFactor = Math.max(0, 1 - bounceH * 2);
|
|
90
|
+
const ballIndent = heightFactor * 20;
|
|
91
|
+
const ballY = BASE_H + WAVE_PEAK - ballIndent + bounceH * MAX_BOUNCE;
|
|
92
|
+
const squish = heightFactor;
|
|
93
|
+
ballParts.push(
|
|
94
|
+
`${pct}%{transform:translate(${(ballIdx * BAR_TOTAL_W).toFixed(2)}px,-${ballY.toFixed(2)}px) scale(${(1 + squish * 0.25).toFixed(3)},${(1 - squish * 0.3).toFixed(3)})}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
lines.push(`@keyframes ${ballName}{${ballParts.join("")}}`);
|
|
98
|
+
|
|
99
|
+
return { css: lines.join(""), barNames, ballName };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function WavePhysicsLoader() {
|
|
103
|
+
// light/dark 各烘焙一套 keyframes,用 html.dark 切换 animation-name:
|
|
104
|
+
// next-themes 的 SSR 内联脚本在渲染前写好 html class,首帧即正确主题、无闪烁无重置
|
|
105
|
+
const light = useMemo(() => buildKeyframes("light"), []);
|
|
106
|
+
const dark = useMemo(() => buildKeyframes("dark"), []);
|
|
107
|
+
|
|
108
|
+
const css = useMemo(() => {
|
|
109
|
+
const lines = [light.css, dark.css];
|
|
110
|
+
for (let i = 0; i < NUM_BARS; i++) {
|
|
111
|
+
lines.push(
|
|
112
|
+
`.wpb-b-${i}{animation:${light.barNames[i]} ${DURATION_S}s linear infinite}`,
|
|
113
|
+
`html.dark .wpb-b-${i}{animation-name:${dark.barNames[i]}}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
lines.push(
|
|
117
|
+
`.wpb-ball{animation:${light.ballName} ${DURATION_S}s linear infinite}`,
|
|
118
|
+
`html.dark .wpb-ball{animation-name:${dark.ballName}}`,
|
|
119
|
+
);
|
|
120
|
+
return `html{--wpb-bar-color:rgb(39 39 42);--wpb-ball-color:${"#18181b"}}html.dark{--wpb-bar-color:rgb(228 228 231);--wpb-ball-color:${"#ffffff"}}${lines.join("")}`;
|
|
121
|
+
}, [light, dark]);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="relative flex w-full flex-col items-center justify-center scale-[0.6] sm:scale-75 md:scale-100">
|
|
125
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
126
|
+
{/* w-[292px]:15 根柱 × 20px 间距的几何总宽,标准刻度类无对应值 */}
|
|
127
|
+
<div className="relative flex h-48 w-[292px] items-end justify-start space-x-2">
|
|
128
|
+
{Array.from({ length: NUM_BARS }, (_, i) => (
|
|
129
|
+
<div
|
|
130
|
+
key={i}
|
|
131
|
+
className={`wpb-b-${i} will-change-transform`}
|
|
132
|
+
style={{
|
|
133
|
+
width: BAR_W,
|
|
134
|
+
height: MAX_H,
|
|
135
|
+
borderRadius: 6,
|
|
136
|
+
transformOrigin: "bottom",
|
|
137
|
+
flexShrink: 0,
|
|
138
|
+
backgroundColor: "var(--wpb-bar-color)",
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
))}
|
|
142
|
+
<div
|
|
143
|
+
className="wpb-ball absolute bottom-0 left-0 z-10 shadow-sm will-change-transform"
|
|
144
|
+
style={{
|
|
145
|
+
width: BAR_W,
|
|
146
|
+
height: BAR_W,
|
|
147
|
+
borderRadius: "50%",
|
|
148
|
+
transformOrigin: "bottom center",
|
|
149
|
+
backgroundColor: "var(--wpb-ball-color)",
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|