@allbluecn/web-app 0.4.22 → 0.5.0

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.
Files changed (70) hide show
  1. package/package.json +8 -8
  2. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  3. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  4. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  5. package/src/components/notifications/settings-notifications.tsx +198 -0
  6. package/src/components/notifications/team-notifications.tsx +343 -0
  7. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  8. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  9. package/src/components/shared/wave-physics-loader.tsx +155 -0
  10. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  11. package/src/components/story/constants.ts +114 -51
  12. package/src/components/story/detail/story-detail-body.tsx +13 -3
  13. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  14. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  15. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  16. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  17. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  18. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  19. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  20. package/src/components/story/peek-panel.tsx +8 -1
  21. package/src/components/story/relations/story-ref-row.tsx +319 -0
  22. package/src/components/story/story-list-view.tsx +3 -1
  23. package/src/components/story/story-panel-header.tsx +4 -1
  24. package/src/components/story/types.ts +17 -18
  25. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  26. package/src/components/story/views/story-list/index.tsx +16 -5
  27. package/src/components/story/views/story-list/row.tsx +5 -4
  28. package/src/components/ui/separator.tsx +1 -1
  29. package/src/hooks/use-notifications.ts +4 -1
  30. package/src/lib/__tests__/datetime.test.ts +16 -1
  31. package/src/lib/changelog/sdk-versions.ts +19 -19
  32. package/src/lib/datetime.ts +1 -0
  33. package/src/routeTree.gen.ts +23 -0
  34. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  35. package/src/routes/_nav/notifications/route.tsx +30 -0
  36. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  37. package/src/routes/api/notifications/stream.ts +4 -0
  38. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  39. package/src/server/notifications/notification-service.ts +137 -27
  40. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  41. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  42. package/src/server/serverFns/locale.ts +15 -3
  43. package/src/server/serverFns/notifications.ts +18 -4
  44. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  45. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  46. package/src/server/serverFns/story/index.ts +1 -2
  47. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  48. package/src/server/serverFns/story/story-groups.ts +23 -3
  49. package/src/server/serverFns/story/story-transfer.ts +5 -3
  50. package/src/server/serverFns/team.ts +51 -2
  51. package/src/components/notifications/notification-list.tsx +0 -232
  52. package/src/components/notifications/notification-preferences.tsx +0 -156
  53. package/src/components/story/relation-badge.tsx +0 -57
  54. package/src/components/story/relations/dependency-panel.tsx +0 -190
  55. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  56. package/src/components/story/relations/tab.tsx +0 -212
  57. package/src/config/sidebar-notifications.ts +0 -50
  58. package/src/plugins/.gen-manifest.json +0 -13
  59. package/src/plugins/modules.gen.ts +0 -20
  60. package/src/plugins/plugins.gen.ts +0 -20
  61. package/src/plugins/ui.gen.ts +0 -8
  62. package/src/routes/_nav/inspiration/route.tsx +0 -8
  63. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  64. package/src/routes/_nav/prd/index.tsx +0 -9
  65. package/src/routes/_nav/prd/route.tsx +0 -8
  66. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  67. package/src/routes/_nav/tags/index.tsx +0 -8
  68. package/src/routes/_nav/tags/route.tsx +0 -8
  69. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  70. package/src/routes/share/prd/$prdId/route.tsx +0 -10
@@ -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 historyInvitations = invitations.filter(
217
- (inv) =>
218
- !inv.link ||
219
- ["accepted", "pending_approval", "revoked", "pending"].includes(inv.status),
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 = invitations.filter(
232
- (i) => i.status === "pending_approval",
233
- )
234
- const acceptedInvitations = invitations.filter(
235
- (i) => i.status === "accepted",
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
- invitationId: string,
360
+ invitation: TeamInvitation,
321
361
  role: "admin" | "member" | "viewer",
322
362
  ) => {
323
- setActionLoading(invitationId)
363
+ setActionLoading(invitation.id)
324
364
  try {
325
- await onApproveLink?.(invitationId, role)
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
- <span>
757
- {t("team.invite.invitedAt")}{" "}
758
- {invitation.invitedBy.name}{" "}
759
- {formatDate(
760
- invitation.createdAt,
761
- timezone,
762
- "MMM d, yyyy",
763
- )}
764
- </span>
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.id, approveRoles[invitation.id] ?? "member")
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
- () => onRevoke!(invitation.id),
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
- <Trash2 className="size-4" />
856
- {t("team.revoke")}
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
- const canResend = ["pending", "expired"].includes(invitation.status) && onResend
864
- const canRevoke = onRevoke
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
- {invitation.link &&
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
- <Button
954
- disabled={actionLoading === invitation.id}
955
- onClick={() =>
956
- handleApprove(invitation.id, approveRoles[invitation.id] ?? "member")
957
- }
958
- size="sm"
959
- type="button"
960
- >
961
- {actionLoading === invitation.id ? (
962
- <Loader2 className="size-4 animate-spin" />
963
- ) : (
964
- <Check className="size-4" />
965
- )}
966
- {t("team.invite.approve")}
967
- </Button>
968
- <DropdownMenu>
969
- <DropdownMenuTrigger asChild>
970
- <Button
971
- aria-label="More options"
972
- size="icon"
973
- type="button"
974
- variant="ghost"
975
- >
976
- <X className="size-4" />
977
- </Button>
978
- </DropdownMenuTrigger>
979
- <DropdownMenuContent
980
- align="end"
981
- collisionPadding={8}
982
- sideOffset={4}
983
- >
984
- <DropdownMenuItem
985
- onSelect={() =>
986
- handleAction(
987
- () => onRevoke!(invitation.id),
988
- invitation.id,
989
- )
990
- }
991
- variant="destructive"
992
- >
993
- <Trash2 className="size-4" />
994
- {t("team.revoke")}
995
- </DropdownMenuItem>
996
- </DropdownMenuContent>
997
- </DropdownMenu>
998
- </div>
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
- {invitations.length === 0 && (
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
+ }