authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-08 14:05:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 14:05:20-07:00
log3faeee49ac67dfd9534369f1e1c627c0356b75ac
tree03442461ac3cf257151c77f8b66ad2fa118d4312
parent5e6cc2b89534a8e01772bf647b79a1f2da2f9664
signature Signed by PGP key B5690EEEBB952194

refactor(web): consolidate setup wizards into shared components (#10832)


7 files changed, 690 insertions(+), 771 deletions(-)

apps/web/src/components/GitActionsControl.tsx+318-377
......@@ -58,7 +58,7 @@ import {
5858 resolveQuickAction,
5959 resolveThreadBranchUpdate,
6060} from "./GitActionsControl.logic";
61import { AnimatedHeight } from "./AnimatedHeight";
61import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard";
6262import { StartTruncatedPath } from "./StartTruncatedPath";
6363import { Button } from "~/components/ui/button";
6464import { Checkbox } from "~/components/ui/checkbox";
......@@ -563,419 +563,360 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
563563
564564 return (
565565 <Dialog open={props.open} onOpenChange={handleOpenChange}>
566 <DialogPopup className="max-w-xl overflow-hidden">
567 <div className="flex min-h-0 flex-col overflow-hidden border-foreground/10 bg-transparent">
568 <DialogHeader className="border-b border-border/70 bg-foreground/[0.025] dark:border-transparent dark:bg-transparent">
569 <DialogTitle>Publish repository</DialogTitle>
570 <DialogDescription>
571 Pick where to host it, then point us at a repo to push to.
572 </DialogDescription>
573 <div className="grid grid-cols-3 gap-2">
574 {publishWizardSteps.map((label, index) => {
575 const isComplete = index < publishWizardStep;
576 const isClickable =
577 publishWizardStep !== 2 &&
578 index < publishWizardSteps.length - 1 &&
579 index <= publishWizardStep;
566 <WizardPopup>
567 <WizardHeader
568 title="Publish repository"
569 description="Pick where to host it, then point us at a repo to push to."
570 >
571 <WizardSteps
572 steps={publishWizardSteps}
573 currentStep={publishWizardStep}
574 summaries={publishWizardStepSummaries}
575 showSummaries
576 isStepDisabled={(index) =>
577 publishWizardStep === 2 ||
578 index >= publishWizardSteps.length - 1 ||
579 index > publishWizardStep
580 }
581 onStepChange={setPublishWizardStep}
582 />
583 </WizardHeader>
584
585 <WizardPanel>
586 <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}>
587 <span id="publish-provider-cards-label" className="text-xs font-medium text-foreground">
588 Provider
589 </span>
590 <RadioGroup
591 value={publishProvider}
592 onValueChange={(value) => {
593 setSelectedPublishProvider(value as PublishProviderKind);
594 setPublishRepositoryOverride(null);
595 }}
596 aria-labelledby="publish-provider-cards-label"
597 className="grid grid-cols-2 gap-2.5"
598 >
599 {sortedPublishProviderOptions.map((option) => {
600 const readiness = publishProviderReadiness[option.value];
601 const isSelected = publishProvider === option.value && readiness.ready;
602 if (!readiness.ready) {
603 return (
604 <div
605 key={option.value}
606 className="relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55 dark:border-transparent dark:bg-white/[0.035]"
607 >
608 <option.Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
609 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
610 {option.label}
611 </span>
612 <Tooltip>
613 <TooltipTrigger
614 render={
615 <Button
616 variant="outline"
617 size="xs"
618 className="h-5 rounded-[.25rem] px-1.5 text-[10px] text-warning-foreground"
619 onClick={(event) => {
620 event.preventDefault();
621 event.stopPropagation();
622 openSourceControlSettings();
623 }}
624 >
625 Setup Required
626 </Button>
627 }
628 />
629 <TooltipPopup side="top" align="end" className="max-w-72">
630 {readiness.hint ??
631 "Open Settings -> Source Control to configure this provider."}
632 </TooltipPopup>
633 </Tooltip>
634 </div>
635 );
636 }
637
580638 return (
581 <button
582 key={label}
583 type="button"
584 onClick={isClickable ? () => setPublishWizardStep(index) : undefined}
585 disabled={!isClickable}
639 <RadioPrimitive.Root
640 key={option.value}
641 value={option.value}
586642 className={cn(
587 "grid min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left",
588 index === publishWizardStep
589 ? "border-primary bg-primary/10 ring-1 ring-primary/25 dark:border-transparent"
590 : isComplete
591 ? "border-border bg-background dark:border-transparent dark:bg-white/[0.05]"
592 : "border-border bg-muted/40 dark:border-transparent dark:bg-white/[0.025]",
593 !isClickable && "cursor-default",
643 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]",
644 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
645 isSelected
646 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
647 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
594648 )}
595649 >
596 <span
597 aria-hidden
598 className={cn(
599 "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
600 isComplete
601 ? "border-primary bg-primary text-primary-foreground"
602 : index === publishWizardStep
603 ? "border-primary bg-background"
604 : "border-muted-foreground/35 bg-background",
605 )}
606 >
607 {isComplete ? <CheckIcon className="size-3" /> : null}
608 </span>
609 <span className="text-[10px] font-medium uppercase text-muted-foreground">
610 Step {index + 1}
650 <option.Icon className="size-5 shrink-0" aria-hidden />
651 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
652 {option.label}
611653 </span>
612 <span className="truncate text-xs font-semibold text-foreground">
613 {label}
614 {isComplete && publishWizardStepSummaries[index]
615 ? `: ${publishWizardStepSummaries[index]}`
616 : ""}
617 </span>
618 </button>
654 </RadioPrimitive.Root>
619655 );
620656 })}
621 </div>
622 </DialogHeader>
623
624 <DialogPanel className="space-y-5 border-b border-border/70 bg-muted/20 px-6 py-5 dark:border-transparent dark:bg-transparent">
625 <AnimatedHeight>
626 <div className={cn("space-y-2", publishWizardStep !== 0 && "hidden")}>
627 <span
628 id="publish-provider-cards-label"
629 className="text-xs font-medium text-foreground"
630 >
631 Provider
657 </RadioGroup>
658 </div>
659
660 <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>
661 <div className="space-y-2">
662 <label
663 htmlFor="publish-repository-path"
664 className="text-xs font-medium text-foreground"
665 >
666 Repository
667 </label>
668 <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring">
669 <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground">
670 <currentPublishProvider.Icon className="size-3.5" />
671 {publishHost}/
632672 </span>
633 <RadioGroup
634 value={publishProvider}
635 onValueChange={(value) => {
636 setSelectedPublishProvider(value as PublishProviderKind);
637 setPublishRepositoryOverride(null);
673 <input
674 id="publish-repository-path"
675 name="publish-repository-path"
676 value={publishRepository}
677 onChange={(event) => {
678 setPublishRepositoryOverride(event.target.value);
638679 }}
639 aria-labelledby="publish-provider-cards-label"
640 className="grid grid-cols-2 gap-2.5"
641 >
642 {sortedPublishProviderOptions.map((option) => {
643 const readiness = publishProviderReadiness[option.value];
644 const isSelected = publishProvider === option.value && readiness.ready;
645 if (!readiness.ready) {
646 return (
647 <div
648 key={option.value}
649 className="relative flex cursor-not-allowed items-center gap-3 rounded-lg border border-border bg-background px-3 py-3 text-left opacity-55 dark:border-transparent dark:bg-white/[0.035]"
650 >
651 <option.Icon
652 className="size-5 shrink-0 text-muted-foreground"
653 aria-hidden
654 />
655 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
656 {option.label}
657 </span>
658 <Tooltip>
659 <TooltipTrigger
660 render={
661 <Button
662 variant="outline"
663 size="xs"
664 className="h-5 rounded-[.25rem] px-1.5 text-[10px] text-warning-foreground"
665 onClick={(event) => {
666 event.preventDefault();
667 event.stopPropagation();
668 openSourceControlSettings();
669 }}
670 >
671 Setup Required
672 </Button>
673 }
674 />
675 <TooltipPopup side="top" align="end" className="max-w-72">
676 {readiness.hint ??
677 "Open Settings -> Source Control to configure this provider."}
678 </TooltipPopup>
679 </Tooltip>
680 </div>
681 );
680 onKeyDown={(event) => {
681 if (event.key === "Enter") {
682 event.preventDefault();
683 submitPublishRepository();
682684 }
685 }}
686 placeholder={publishPathPlaceholder}
687 disabled={publishRepositoryAction.isPending}
688 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
689 />
690 </div>
691 </div>
683692
684 return (
685 <RadioPrimitive.Root
686 key={option.value}
687 value={option.value}
688 className={cn(
689 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-3 text-left outline-none transition-[background-color,border-color,box-shadow]",
690 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
691 isSelected
692 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
693 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
694 )}
695 >
696 <option.Icon className="size-5 shrink-0" aria-hidden />
697 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
693 <div className="space-y-2">
694 <span
695 id="publish-visibility-cards-label"
696 className="text-xs font-medium text-foreground"
697 >
698 Visibility
699 </span>
700 <RadioGroup
701 value={publishVisibility}
702 onValueChange={(value) =>
703 setPublishVisibility(value as SourceControlRepositoryVisibility)
704 }
705 aria-labelledby="publish-visibility-cards-label"
706 disabled={publishRepositoryAction.isPending}
707 className="grid grid-cols-2 gap-2.5"
708 >
709 {[
710 {
711 value: "private" as const,
712 label: "Private",
713 description: "Only invited people",
714 Icon: LockIcon,
715 },
716 {
717 value: "public" as const,
718 label: "Public",
719 description: "Anyone on the web",
720 Icon: GlobeIcon,
721 },
722 ].map((option) => {
723 const isSelected = publishVisibility === option.value;
724 return (
725 <RadioPrimitive.Root
726 key={option.value}
727 value={option.value}
728 className={cn(
729 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]",
730 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
731 isSelected
732 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
733 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
734 )}
735 >
736 <option.Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
737 <span className="min-w-0 flex-1">
738 <span className="block text-sm font-medium text-foreground">
698739 {option.label}
699740 </span>
700 </RadioPrimitive.Root>
701 );
702 })}
703 </RadioGroup>
704 </div>
741 <span className="block text-xs text-muted-foreground">
742 {option.description}
743 </span>
744 </span>
745 </RadioPrimitive.Root>
746 );
747 })}
748 </RadioGroup>
749 </div>
705750
706 <div className={cn("space-y-5", publishWizardStep !== 1 && "hidden")}>
707 <div className="space-y-2">
708 <label
709 htmlFor="publish-repository-path"
710 className="text-xs font-medium text-foreground"
711 >
712 Repository
751 <div>
752 <button
753 type="button"
754 onClick={() => setPublishAdvancedOpen((prev) => !prev)}
755 aria-expanded={publishAdvancedOpen}
756 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
757 >
758 <ChevronDownIcon
759 className={cn(
760 "size-3.5 transition-transform",
761 publishAdvancedOpen ? "" : "-rotate-90",
762 )}
763 />
764 Advanced
765 </button>
766 {publishAdvancedOpen ? (
767 <div className="mt-3 grid gap-3 sm:grid-cols-2">
768 <label className="space-y-1.5" htmlFor="publish-remote-name">
769 <span className="text-xs font-medium text-foreground">Remote</span>
770 <Input
771 id="publish-remote-name"
772 value={publishRemoteName}
773 onChange={(event) => setPublishRemoteName(event.target.value)}
774 placeholder="origin"
775 disabled={publishRepositoryAction.isPending}
776 />
713777 </label>
714 <div className="flex items-stretch overflow-hidden rounded-md border border-input bg-background focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring">
715 <span className="flex shrink-0 items-center gap-1.5 border-r border-input bg-muted/50 px-2.5 font-mono text-xs text-muted-foreground">
716 <currentPublishProvider.Icon className="size-3.5" />
717 {publishHost}/
778 <div className="space-y-1.5">
779 <span
780 id="publish-protocol-label"
781 className="text-xs font-medium text-foreground"
782 >
783 Protocol
718784 </span>
719 <input
720 id="publish-repository-path"
721 name="publish-repository-path"
722 value={publishRepository}
723 onChange={(event) => {
724 setPublishRepositoryOverride(event.target.value);
725 }}
726 onKeyDown={(event) => {
727 if (event.key === "Enter") {
728 event.preventDefault();
729 submitPublishRepository();
785 <RadioGroup
786 className="w-fit flex-row gap-0.5 rounded-lg bg-input/40 p-0.5"
787 value={publishProtocol}
788 onValueChange={(protocol) => {
789 if (protocol === "ssh" || protocol === "https") {
790 setPublishProtocol(protocol);
730791 }
731792 }}
732 placeholder={publishPathPlaceholder}
793 aria-labelledby="publish-protocol-label"
733794 disabled={publishRepositoryAction.isPending}
734 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
735 />
736 </div>
737 </div>
738
739 <div className="space-y-2">
740 <span
741 id="publish-visibility-cards-label"
742 className="text-xs font-medium text-foreground"
743 >
744 Visibility
745 </span>
746 <RadioGroup
747 value={publishVisibility}
748 onValueChange={(value) =>
749 setPublishVisibility(value as SourceControlRepositoryVisibility)
750 }
751 aria-labelledby="publish-visibility-cards-label"
752 disabled={publishRepositoryAction.isPending}
753 className="grid grid-cols-2 gap-2.5"
754 >
755 {[
756 {
757 value: "private" as const,
758 label: "Private",
759 description: "Only invited people",
760 Icon: LockIcon,
761 },
762 {
763 value: "public" as const,
764 label: "Public",
765 description: "Anyone on the web",
766 Icon: GlobeIcon,
767 },
768 ].map((option) => {
769 const isSelected = publishVisibility === option.value;
770 return (
795 >
796 {(["ssh", "https"] as const).map((protocol) => (
771797 <RadioPrimitive.Root
772 key={option.value}
773 value={option.value}
774 className={cn(
775 "relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 text-left outline-none transition-[background-color,border-color,box-shadow]",
776 "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
777 isSelected
778 ? "border-primary bg-background shadow-sm ring-2 ring-primary/35 dark:border-transparent dark:bg-primary/10 dark:shadow-none dark:ring-1 dark:ring-primary/30"
779 : "border-border bg-background hover:border-foreground/20 hover:bg-muted/50 dark:border-transparent dark:bg-white/[0.035] dark:hover:bg-accent",
780 )}
798 key={protocol}
799 value={protocol}
800 data-pressed={publishProtocol === protocol ? "" : undefined}
801 className={toggleVariants({
802 variant: "segmented",
803 size: "segmented",
804 })}
781805 >
782 <option.Icon
783 className="size-4 shrink-0 text-muted-foreground"
784 aria-hidden
785 />
786 <span className="min-w-0 flex-1">
787 <span className="block text-sm font-medium text-foreground">
788 {option.label}
789 </span>
790 <span className="block text-xs text-muted-foreground">
791 {option.description}
792 </span>
793 </span>
806 {protocol.toUpperCase()}
794807 </RadioPrimitive.Root>
795 );
796 })}
797 </RadioGroup>
798 </div>
799
800 <div>
801 <button
802 type="button"
803 onClick={() => setPublishAdvancedOpen((prev) => !prev)}
804 aria-expanded={publishAdvancedOpen}
805 className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
806 >
807 <ChevronDownIcon
808 className={cn(
809 "size-3.5 transition-transform",
810 publishAdvancedOpen ? "" : "-rotate-90",
811 )}
812 />
813 Advanced
814 </button>
815 {publishAdvancedOpen ? (
816 <div className="mt-3 grid gap-3 sm:grid-cols-2">
817 <label className="space-y-1.5" htmlFor="publish-remote-name">
818 <span className="text-xs font-medium text-foreground">Remote</span>
819 <Input
820 id="publish-remote-name"
821 value={publishRemoteName}
822 onChange={(event) => setPublishRemoteName(event.target.value)}
823 placeholder="origin"
824 disabled={publishRepositoryAction.isPending}
825 />
826 </label>
827 <div className="space-y-1.5">
828 <span
829 id="publish-protocol-label"
830 className="text-xs font-medium text-foreground"
831 >
832 Protocol
833 </span>
834 <RadioGroup
835 className="w-fit flex-row gap-0.5 rounded-lg bg-input/40 p-0.5"
836 value={publishProtocol}
837 onValueChange={(protocol) => {
838 if (protocol === "ssh" || protocol === "https") {
839 setPublishProtocol(protocol);
840 }
841 }}
842 aria-labelledby="publish-protocol-label"
843 disabled={publishRepositoryAction.isPending}
844 >
845 {(["ssh", "https"] as const).map((protocol) => (
846 <RadioPrimitive.Root
847 key={protocol}
848 value={protocol}
849 data-pressed={publishProtocol === protocol ? "" : undefined}
850 className={toggleVariants({
851 variant: "segmented",
852 size: "segmented",
853 })}
854 >
855 {protocol.toUpperCase()}
856 </RadioPrimitive.Root>
857 ))}
858 </RadioGroup>
859 </div>
860 </div>
861 ) : null}
808 ))}
809 </RadioGroup>
810 </div>
862811 </div>
812 ) : null}
813 </div>
863814
864 {publishRepositoryAction.isPending ? (
865 <div
866 role="status"
867 aria-live="polite"
868 className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]"
869 >
870 <Spinner className="size-3.5" aria-hidden />
871 Publishing repository to {publishProviderLabel}...
872 </div>
873 ) : null}
874 {publishError && !publishRepositoryAction.isPending ? (
875 <div
876 role="alert"
877 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
878 >
879 <p className="font-medium">Publish failed</p>
880 <p className="mt-0.5 text-destructive/90">{publishError}</p>
881 </div>
882 ) : null}
815 {publishRepositoryAction.isPending ? (
816 <div
817 role="status"
818 aria-live="polite"
819 className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]"
820 >
821 <Spinner className="size-3.5" aria-hidden />
822 Publishing repository to {publishProviderLabel}...
883823 </div>
884
885 <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>
886 {publishResult ? (
887 <>
888 <div className="flex flex-col items-center gap-2 py-1 text-center">
889 <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success">
890 <CheckIcon className="size-4" aria-hidden />
891 </span>
892 <h3 className="text-sm font-semibold text-foreground">
893 {publishResult.status === "pushed"
894 ? "Repository published"
895 : "Repository created"}
896 </h3>
897 <p className="max-w-xs text-pretty text-xs text-muted-foreground">
898 {publishResult.status === "pushed"
899 ? `${publishResult.branch} is now live on ${publishProviderLabel}.`
900 : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`}
901 </p>
902 </div>
903 <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2 dark:border-transparent dark:bg-white/[0.035]">
904 <currentPublishProvider.Icon className="size-3.5 shrink-0 text-muted-foreground" />
905 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
906 {publishResult.repository.nameWithOwner}
907 </span>
908 </div>
909 <Button
910 variant="outline"
911 size="sm"
912 className="w-full"
913 onClick={() => {
914 void openLink(publishResult.repository.url).catch(() => undefined);
915 }}
916 >
917 Open on {publishProviderLabel}
918 </Button>
919 </>
920 ) : (
921 <div className="rounded-md border border-input bg-background px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]">
922 Publish result unavailable.
923 </div>
924 )}
824 ) : null}
825 {publishError && !publishRepositoryAction.isPending ? (
826 <div
827 role="alert"
828 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
829 >
830 <p className="font-medium">Publish failed</p>
831 <p className="mt-0.5 text-destructive/90">{publishError}</p>
925832 </div>
926 </AnimatedHeight>
927 </DialogPanel>
833 ) : null}
834 </div>
928835
929 <DialogFooter className="dark:border-transparent dark:bg-transparent">
930 {publishWizardStep === 2 ? (
931 <Button size="sm" onClick={() => handleOpenChange(false)}>
932 Done
933 </Button>
934 ) : (
836 <div className={cn("space-y-4", publishWizardStep !== 2 && "hidden")}>
837 {publishResult ? (
935838 <>
839 <div className="flex flex-col items-center gap-2 py-1 text-center">
840 <span className="grid size-8 place-items-center rounded-full bg-success/15 text-success">
841 <CheckIcon className="size-4" aria-hidden />
842 </span>
843 <h3 className="text-sm font-semibold text-foreground">
844 {publishResult.status === "pushed"
845 ? "Repository published"
846 : "Repository created"}
847 </h3>
848 <p className="max-w-xs text-pretty text-xs text-muted-foreground">
849 {publishResult.status === "pushed"
850 ? `${publishResult.branch} is now live on ${publishProviderLabel}.`
851 : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`}
852 </p>
853 </div>
854 <div className="flex items-center gap-2 rounded-lg border border-input bg-muted/40 px-3 py-2 dark:border-transparent dark:bg-white/[0.035]">
855 <currentPublishProvider.Icon className="size-3.5 shrink-0 text-muted-foreground" />
856 <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
857 {publishResult.repository.nameWithOwner}
858 </span>
859 </div>
936860 <Button
937861 variant="outline"
938862 size="sm"
939 disabled={publishRepositoryAction.isPending}
863 className="w-full"
940864 onClick={() => {
941 if (publishWizardStep === 0) {
942 handleOpenChange(false);
943 return;
944 }
945 setPublishWizardStep((step) => Math.max(0, step - 1));
865 void openLink(publishResult.repository.url).catch(() => undefined);
946866 }}
947867 >
948 {publishWizardStep === 0 ? "Cancel" : "Back"}
868 Open on {publishProviderLabel}
949869 </Button>
950 {publishWizardStep < 1 ? (
951 <Button
952 size="sm"
953 disabled={!hasReadyPublishProvider || !selectedPublishProviderReadiness.ready}
954 onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))}
955 >
956 Next
957 </Button>
958 ) : (
959 <Button
960 size="sm"
961 disabled={!canSubmitPublishRepository}
962 onClick={submitPublishRepository}
963 >
964 {publishRepositoryAction.isPending ? (
965 <>
966 <Spinner className="size-3.5" aria-hidden />
967 Publishing...
968 </>
969 ) : (
970 "Publish"
971 )}
972 </Button>
973 )}
974870 </>
871 ) : (
872 <div className="rounded-md border border-input bg-background px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]">
873 Publish result unavailable.
874 </div>
975875 )}
976 </DialogFooter>
977 </div>
978 </DialogPopup>
876 </div>
877 </WizardPanel>
878
879 <WizardFooter>
880 {publishWizardStep === 2 ? (
881 <Button onClick={() => handleOpenChange(false)}>Done</Button>
882 ) : (
883 <>
884 <Button
885 variant="outline"
886 disabled={publishRepositoryAction.isPending}
887 onClick={() => {
888 if (publishWizardStep === 0) {
889 handleOpenChange(false);
890 return;
891 }
892 setPublishWizardStep((step) => Math.max(0, step - 1));
893 }}
894 >
895 {publishWizardStep === 0 ? "Cancel" : "Back"}
896 </Button>
897 {publishWizardStep < 1 ? (
898 <Button
899 disabled={!hasReadyPublishProvider || !selectedPublishProviderReadiness.ready}
900 onClick={() => setPublishWizardStep((step) => Math.min(1, step + 1))}
901 >
902 Next
903 </Button>
904 ) : (
905 <Button disabled={!canSubmitPublishRepository} onClick={submitPublishRepository}>
906 {publishRepositoryAction.isPending ? (
907 <>
908 <Spinner className="size-3.5" aria-hidden />
909 Publishing...
910 </>
911 ) : (
912 "Publish"
913 )}
914 </Button>
915 )}
916 </>
917 )}
918 </WizardFooter>
919 </WizardPopup>
979920 </Dialog>
980921 );
981922}
apps/web/src/components/cloud/ConnectOnboardingDialog.tsx+51-54
......@@ -15,18 +15,10 @@ import { useEnvironments, usePrimaryEnvironment } from "~/state/environments";
1515import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList";
1616import { Button } from "../ui/button";
1717import { Checkbox } from "../ui/checkbox";
18import {
19 Dialog,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogPanel,
24 DialogPopup,
25 DialogTitle,
26} from "../ui/dialog";
18import { Dialog } from "../ui/dialog";
2719import { Switch } from "../ui/switch";
2820import { toastManager } from "../ui/toast";
29import { WizardSteps } from "../ui/wizard-steps";
21import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
3022
3123/**
3224 * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session
......@@ -216,23 +208,29 @@ function ConfiguredConnectOnboardingDialog() {
216208 if (!open && !isApplying) complete();
217209 }}
218210 >
219 <DialogPopup className="max-w-xl">
220 <DialogHeader>
221 <DialogTitle>Set up T3 Connect</DialogTitle>
222 <DialogDescription>
223 Mesh your devices together — publish this environment and connect the rest, all in one
224 place.
225 </DialogDescription>
211 <WizardPopup>
212 <WizardHeader
213 title="Set up T3 Connect"
214 description={
215 <>
216 Mesh your devices together — publish this environment and connect the rest, all in one
217 place.
218 </>
219 }
220 >
226221 {steps.length > 1 ? (
227222 <WizardSteps
228 steps={steps.map((id) => ({ id, label: STEP_LABELS[id] }))}
229 currentStep={step}
230 disabled={isApplying}
231 onStepSelect={setStep}
223 steps={steps.map((id) => STEP_LABELS[id])}
224 currentStep={steps.indexOf(step)}
225 isStepDisabled={() => isApplying}
226 onStepChange={(index) => {
227 const next = steps[index];
228 if (next) setStep(next);
229 }}
232230 />
233231 ) : null}
234 </DialogHeader>
235 <DialogPanel>
232 </WizardHeader>
233 <WizardPanel>
236234 {step === "publish" ? (
237235 <PublishStep
238236 exposeEnvironment={exposeEnvironment}
......@@ -245,38 +243,37 @@ function ConfiguredConnectOnboardingDialog() {
245243 ) : (
246244 <DevicesStep />
247245 )}
248 </DialogPanel>
249 <DialogFooter variant="bare" className="sm:justify-between">
250 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
251 <Checkbox
252 checked={dontShowAgain}
253 onCheckedChange={(checked) => setDontShowAgain(checked === true)}
254 />
255 Don&apos;t show this again
256 </label>
257 <div className="flex flex-col-reverse gap-2 sm:flex-row">
258 {step === "publish" ? (
259 <>
260 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
261 Not now
262 </Button>
263 <Button
264 disabled={
265 isApplying || (controller.linkState.isPending && linkStateData === null)
266 }
267 onClick={() => void applyPublishSelection()}
268 >
269 {isApplying ? "Enabling…" : "Continue"}
270 </Button>
271 </>
272 ) : (
273 <Button disabled={isApplying} onClick={complete}>
274 Done
246 </WizardPanel>
247 <WizardFooter
248 leading={
249 <label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground sm:self-center">
250 <Checkbox
251 checked={dontShowAgain}
252 onCheckedChange={(checked) => setDontShowAgain(checked === true)}
253 />
254 Don&apos;t show this again
255 </label>
256 }
257 >
258 {step === "publish" ? (
259 <>
260 <Button variant="ghost" disabled={isApplying} onClick={() => setStep("devices")}>
261 Not now
275262 </Button>
276 )}
277 </div>
278 </DialogFooter>
279 </DialogPopup>
263 <Button
264 disabled={isApplying || (controller.linkState.isPending && linkStateData === null)}
265 onClick={() => void applyPublishSelection()}
266 >
267 {isApplying ? "Enabling…" : "Continue"}
268 </Button>
269 </>
270 ) : (
271 <Button disabled={isApplying} onClick={complete}>
272 Done
273 </Button>
274 )}
275 </WizardFooter>
276 </WizardPopup>
280277 </Dialog>
281278 );
282279}
apps/web/src/components/onboarding/WelcomeWizard.tsx+55-55
......@@ -71,8 +71,8 @@ import { Input } from "../ui/input";
7171import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
7272import { ScrollArea } from "../ui/scroll-area";
7373import { Spinner } from "../ui/spinner";
74import { WizardPanel, WizardSteps } from "../ui/wizard";
75import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";
74import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard";
75import { Dialog } from "../ui/dialog";
7676import { toastManager } from "../ui/toast";
7777import { cn } from "../../lib/utils";
7878import { formatRelativeTime } from "../../timestampFormat";
......@@ -183,71 +183,71 @@ export function WelcomeWizard({
183183
184184 return (
185185 <Dialog open disablePointerDismissal onOpenChange={(_, event) => event.cancel()}>
186 <DialogPopup
187 className="max-w-xl overflow-x-hidden overflow-y-auto"
186 <WizardPopup
188187 bottomStickOnMobile={false}
189188 showCloseButton={false}
190189 initialFocus={() => document.getElementById("onboarding-pairing-url") ?? true}
191190 >
192 <DialogTitle className="sr-only">Set up T3 Code</DialogTitle>
193 <div className="flex min-h-0 flex-col">
194 <DialogHeader className="gap-4">
191 <WizardHeader
192 title="Set up T3 Code"
193 identity={
195194 <div className="flex items-baseline gap-1.5" role="img" aria-label="T3 Code">
196195 <T3Wordmark className="h-4 w-auto shrink-0" aria-hidden />
197196 <span className="text-[1.4rem] font-medium tracking-tight text-muted-foreground">
198197 Code
199198 </span>
200199 </div>
201 <WizardSteps
202 steps={ONBOARDING_STAGES}
203 currentStep={stageIndex}
204 isStepDisabled={(index) => isImporting || index >= stageIndex}
205 onStepChange={(index) => {
206 if (isImporting || index > stageIndex) return;
207 setStep(index === 0 ? "connection" : "agents");
200 }
201 >
202 <WizardSteps
203 steps={ONBOARDING_STAGES}
204 currentStep={stageIndex}
205 isStepDisabled={(index) => isImporting || index >= stageIndex}
206 onStepChange={(index) => {
207 if (isImporting || index > stageIndex) return;
208 setStep(index === 0 ? "connection" : "agents");
209 }}
210 />
211 </WizardHeader>
212
213 <WizardPanel holdHeight={isLoadingProjects}>
214 {step === "connection" ? (
215 <ConnectionStep
216 expandPairingInitially={!localAvailable && !hasCloudPublicConfig()}
217 selectedIds={selectedIds}
218 autoSelectedComputers={autoSelectedComputers.current}
219 onSelectionChange={setSelection}
220 onToggleEnvironment={(environmentId, checked) =>
221 setSelection((current) => {
222 const next = new Set(current ?? selectedIds);
223 if (checked) next.add(environmentId);
224 else next.delete(environmentId);
225 return next;
226 })
227 }
228 onContinue={() =>
229 startSetup(
230 environments
231 .filter((environment) => selectedIds.has(environment.environmentId))
232 .map((environment) => environment.environmentId),
233 )
234 }
235 onPaired={(environmentId) => {
236 setSelection(new Set([...selectedIds, environmentId]));
208237 }}
209238 />
210 </DialogHeader>
211
212 <WizardPanel className="min-w-0" holdHeight={isLoadingProjects}>
213 {step === "connection" ? (
214 <ConnectionStep
215 expandPairingInitially={!localAvailable && !hasCloudPublicConfig()}
216 selectedIds={selectedIds}
217 autoSelectedComputers={autoSelectedComputers.current}
218 onSelectionChange={setSelection}
219 onToggleEnvironment={(environmentId, checked) =>
220 setSelection((current) => {
221 const next = new Set(current ?? selectedIds);
222 if (checked) next.add(environmentId);
223 else next.delete(environmentId);
224 return next;
225 })
226 }
227 onContinue={() =>
228 startSetup(
229 environments
230 .filter((environment) => selectedIds.has(environment.environmentId))
231 .map((environment) => environment.environmentId),
232 )
233 }
234 onPaired={(environmentId) => {
235 setSelection(new Set([...selectedIds, environmentId]));
236 }}
237 />
238 ) : step === "agents" ? (
239 <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
240 ) : (
241 <ImportStep
242 scans={scans}
243 isImporting={isImporting}
244 setIsImporting={setIsImporting}
245 onDone={finish}
246 />
247 )}
248 </WizardPanel>
249 </div>
250 </DialogPopup>
239 ) : step === "agents" ? (
240 <AgentsStep environmentIds={setupIds} onContinue={() => setStep("import")} />
241 ) : (
242 <ImportStep
243 scans={scans}
244 isImporting={isImporting}
245 setIsImporting={setIsImporting}
246 onDone={finish}
247 />
248 )}
249 </WizardPanel>
250 </WizardPopup>
251251 </Dialog>
252252 );
253253}
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+185-194
......@@ -15,21 +15,14 @@ import { cn } from "../../lib/utils";
1515import { normalizeProviderAccentColor } from "../../providerInstances";
1616import { Button } from "../ui/button";
1717import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
18import {
19 Dialog,
20 DialogDescription,
21 DialogFooter,
22 DialogHeader,
23 DialogPopup,
24 DialogTitle,
25} from "../ui/dialog";
18import { Dialog } from "../ui/dialog";
2619import { Badge } from "../ui/badge";
2720import { Input } from "../ui/input";
2821import { RadioGroup } from "../ui/radio-group";
2922import { toastManager } from "../ui/toast";
3023import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
3124import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
32import { WizardPanel } from "../ui/wizard";
25import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard";
3326import {
3427 ADD_PROVIDER_WIZARD_STEPS,
3528 resolveWizardNavigation,
......@@ -230,206 +223,204 @@ export function AddProviderInstanceDialog({
230223
231224 return (
232225 <Dialog open={open} onOpenChange={onOpenChange}>
233 <DialogPopup className="max-w-xl overflow-hidden">
234 <div className="flex min-h-0 flex-col overflow-hidden">
235 <DialogHeader>
236 <DialogTitle>Add provider instance</DialogTitle>
237 <DialogDescription>
226 <WizardPopup>
227 <WizardHeader
228 title="Add provider instance"
229 description={
230 <>
238231 Configure an additional provider instance on {environmentLabel} — for example, a
239232 second Codex install pointed at a different workspace.
240 </DialogDescription>
241 <AddProviderInstanceWizardSteps
242 currentStep={wizardStep}
243 summaries={wizardStepSummaries}
244 instanceIdError={instanceIdError}
245 onNavigation={applyWizardNavigation}
246 />
247 </DialogHeader>
233 </>
234 }
235 >
236 <AddProviderInstanceWizardSteps
237 currentStep={wizardStep}
238 summaries={wizardStepSummaries}
239 instanceIdError={instanceIdError}
240 onNavigation={applyWizardNavigation}
241 />
242 </WizardHeader>
248243
249 <WizardPanel>
250 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
251 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
252 Driver
253 </div>
254 <RadioGroup
255 value={driver}
256 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
257 aria-labelledby="add-instance-driver-label"
258 className="grid grid-cols-1 gap-2 sm:grid-cols-2"
259 >
260 {DRIVER_OPTIONS.map((option) => {
261 const IconComponent = option.icon;
262 return (
263 <RadioPrimitive.Root
264 key={option.value}
265 value={option.value}
266 className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"
267 >
268 <IconComponent className="size-4 shrink-0" aria-hidden />
269 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
270 {option.label}
271 </span>
272 <RadioPrimitive.Indicator
273 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
274 aria-hidden
275 >
276 <CheckIcon className="size-3.5 shrink-0" />
277 </RadioPrimitive.Indicator>
278 {option.badgeLabel ? (
279 <Badge variant="warning" size="sm">
280 {option.badgeLabel}
281 </Badge>
282 ) : null}
283 </RadioPrimitive.Root>
284 );
285 })}
286 {COMING_SOON_DRIVER_OPTIONS.map((option) => {
287 const IconComponent = option.icon;
288 return (
289 <RadioPrimitive.Root
290 key={option.value}
291 value={option.value}
292 disabled
293 className={cn(
294 "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
295 )}
244 <WizardPanel>
245 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
246 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
247 Driver
248 </div>
249 <RadioGroup
250 value={driver}
251 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
252 aria-labelledby="add-instance-driver-label"
253 className="grid grid-cols-1 gap-2 sm:grid-cols-2"
254 >
255 {DRIVER_OPTIONS.map((option) => {
256 const IconComponent = option.icon;
257 return (
258 <RadioPrimitive.Root
259 key={option.value}
260 value={option.value}
261 className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"
262 >
263 <IconComponent className="size-4 shrink-0" aria-hidden />
264 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
265 {option.label}
266 </span>
267 <RadioPrimitive.Indicator
268 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
269 aria-hidden
296270 >
297 <IconComponent
298 className="size-4 shrink-0 text-muted-foreground"
299 aria-hidden
300 />
301 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
302 {option.label}
303 </span>
271 <CheckIcon className="size-3.5 shrink-0" />
272 </RadioPrimitive.Indicator>
273 {option.badgeLabel ? (
304274 <Badge variant="warning" size="sm">
305 Coming Soon
275 {option.badgeLabel}
306276 </Badge>
307 </RadioPrimitive.Root>
308 );
309 })}
310 </RadioGroup>
311 </div>
277 ) : null}
278 </RadioPrimitive.Root>
279 );
280 })}
281 {COMING_SOON_DRIVER_OPTIONS.map((option) => {
282 const IconComponent = option.icon;
283 return (
284 <RadioPrimitive.Root
285 key={option.value}
286 value={option.value}
287 disabled
288 className={cn(
289 "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
290 )}
291 >
292 <IconComponent className="size-4 shrink-0 text-muted-foreground" aria-hidden />
293 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
294 {option.label}
295 </span>
296 <Badge variant="warning" size="sm">
297 Coming Soon
298 </Badge>
299 </RadioPrimitive.Root>
300 );
301 })}
302 </RadioGroup>
303 </div>
312304
313 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
314 <span className="text-xs font-medium text-foreground">Label</span>
315 <Input
316 className="bg-background"
317 placeholder="e.g. Work"
318 value={label}
319 onChange={(event) => setLabel(event.target.value)}
320 />
305 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
306 <span className="text-xs font-medium text-foreground">Label</span>
307 <Input
308 className="bg-background"
309 placeholder="e.g. Work"
310 value={label}
311 onChange={(event) => setLabel(event.target.value)}
312 />
313 <span className="text-[11px] text-muted-foreground">
314 Shown in the provider list. Optional.
315 </span>
316 </label>
317
318 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
319 <span className="text-xs font-medium text-foreground">Instance ID</span>
320 <Input
321 className="bg-background"
322 placeholder={`${driver}_work`}
323 value={instanceId}
324 onChange={(event) => {
325 setInstanceIdOverride(event.target.value);
326 }}
327 aria-invalid={showInstanceIdError}
328 />
329 {showInstanceIdError ? (
330 <span className="text-[11px] text-destructive">{instanceIdError}</span>
331 ) : (
321332 <span className="text-[11px] text-muted-foreground">
322 Shown in the provider list. Optional.
333 Routing key used by threads and sessions. Letters, digits, '-', or '_'.
323334 </span>
324 </label>
335 )}
336 </label>
325337
326 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
327 <span className="text-xs font-medium text-foreground">Instance ID</span>
328 <Input
329 className="bg-background"
330 placeholder={`${driver}_work`}
331 value={instanceId}
332 onChange={(event) => {
333 setInstanceIdOverride(event.target.value);
334 }}
335 aria-invalid={showInstanceIdError}
338 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
339 <span className="text-xs font-medium text-foreground">Accent color</span>
340 <div className="flex min-w-0 flex-wrap items-center gap-2">
341 <input
342 type="color"
343 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
344 onChange={(event) => setAccentColor(event.target.value)}
345 aria-label="Provider instance accent color"
346 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
336347 />
337 {showInstanceIdError ? (
338 <span className="text-[11px] text-destructive">{instanceIdError}</span>
339 ) : (
340 <span className="text-[11px] text-muted-foreground">
341 Routing key used by threads and sessions. Letters, digits, '-', or '_'.
342 </span>
343 )}
344 </label>
345
346 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
347 <span className="text-xs font-medium text-foreground">Accent color</span>
348 <div className="flex min-w-0 flex-wrap items-center gap-2">
349 <input
350 type="color"
351 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
352 onChange={(event) => setAccentColor(event.target.value)}
353 aria-label="Provider instance accent color"
354 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
355 />
356 <div className="flex flex-wrap gap-1.5">
357 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
358 const selected = accentColor.toLowerCase() === swatch;
359 return (
360 <button
361 key={swatch}
362 type="button"
363 className={cn(
364 "size-6 cursor-pointer rounded-full border transition",
365 selected
366 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
367 : "border-black/10 hover:scale-105 dark:border-white/20",
368 )}
369 style={{ backgroundColor: swatch }}
370 onClick={() => setAccentColor(swatch)}
371 aria-label={`Use ${swatch} accent`}
372 />
373 );
374 })}
375 </div>
376 {accentColor ? (
377 <Button
378 type="button"
379 size="xs"
380 variant="ghost"
381 className="text-muted-foreground"
382 onClick={() => setAccentColor("")}
383 >
384 Clear
385 </Button>
386 ) : null}
348 <div className="flex flex-wrap gap-1.5">
349 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
350 const selected = accentColor.toLowerCase() === swatch;
351 return (
352 <button
353 key={swatch}
354 type="button"
355 className={cn(
356 "size-6 cursor-pointer rounded-full border transition",
357 selected
358 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
359 : "border-black/10 hover:scale-105 dark:border-white/20",
360 )}
361 style={{ backgroundColor: swatch }}
362 onClick={() => setAccentColor(swatch)}
363 aria-label={`Use ${swatch} accent`}
364 />
365 );
366 })}
387367 </div>
388 <span className="text-[11px] text-muted-foreground">
389 Optional marker shown in the picker.
390 </span>
368 {accentColor ? (
369 <Button
370 type="button"
371 size="xs"
372 variant="ghost"
373 className="text-muted-foreground"
374 onClick={() => setAccentColor("")}
375 >
376 Clear
377 </Button>
378 ) : null}
391379 </div>
380 <span className="text-[11px] text-muted-foreground">
381 Optional marker shown in the picker.
382 </span>
383 </div>
392384
393 {driverSettingsFields.length > 0 ? (
394 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
395 <ProviderSettingsForm
396 definition={driverOption}
397 value={configDraft}
398 idPrefix={`add-provider-${driver}`}
399 variant="dialog"
400 onChange={setConfigDraft}
401 />
402 </div>
403 ) : wizardStep === 2 ? (
404 <div className="grid gap-2">
405 <p className="text-sm text-muted-foreground">
406 This driver has no required configuration. You can add the instance now.
407 </p>
408 </div>
409 ) : null}
410 </WizardPanel>
385 {driverSettingsFields.length > 0 ? (
386 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
387 <ProviderSettingsForm
388 definition={driverOption}
389 value={configDraft}
390 idPrefix={`add-provider-${driver}`}
391 variant="dialog"
392 onChange={setConfigDraft}
393 />
394 </div>
395 ) : wizardStep === 2 ? (
396 <div className="grid gap-2">
397 <p className="text-sm text-muted-foreground">
398 This driver has no required configuration. You can add the instance now.
399 </p>
400 </div>
401 ) : null}
402 </WizardPanel>
411403
412 <DialogFooter variant="bare">
413 <Button
414 variant="outline"
415 onClick={() => {
416 if (wizardStep === 0) {
417 onOpenChange(false);
418 return;
419 }
420 setWizardStep((step) => Math.max(0, step - 1));
421 }}
422 >
423 {wizardStep === 0 ? "Cancel" : "Back"}
424 </Button>
425 {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
426 <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>
427 ) : (
428 <Button onClick={handleSave}>Add instance</Button>
429 )}
430 </DialogFooter>
431 </div>
432 </DialogPopup>
404 <WizardFooter>
405 <Button
406 variant="outline"
407 onClick={() => {
408 if (wizardStep === 0) {
409 onOpenChange(false);
410 return;
411 }
412 setWizardStep((step) => Math.max(0, step - 1));
413 }}
414 >
415 {wizardStep === 0 ? "Cancel" : "Back"}
416 </Button>
417 {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
418 <Button onClick={() => navigateToStep(wizardStep + 1)}>Next</Button>
419 ) : (
420 <Button onClick={handleSave}>Add instance</Button>
421 )}
422 </WizardFooter>
423 </WizardPopup>
433424 </Dialog>
434425 );
435426}
apps/web/src/components/settings/SnapShotSetupDialog.tsx+16-26
......@@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react";
77import { useEffect, useId, useState, type ReactNode } from "react";
88import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
99import { Button } from "../ui/button";
10import {
11 Dialog,
12 DialogDescription,
13 DialogFooter,
14 DialogHeader,
15 DialogPanel,
16 DialogPopup,
17 DialogTitle,
18} from "../ui/dialog";
19import { WizardSteps } from "../ui/wizard-steps";
10import { Dialog, DialogDescription } from "../ui/dialog";
11import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard";
2012import {
2113 captureSetupAccessReady,
2214 captureSetupBackend,
......@@ -335,21 +327,19 @@ export function SnapShotSetupDialog({
335327 if (!open && !busy) void onClose(false);
336328 }}
337329 >
338 <DialogPopup className="max-w-xl" showCloseButton={!busy}>
339 <DialogHeader>
340 <DialogTitle>
341 {desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"}
342 </DialogTitle>
330 <WizardPopup showCloseButton={!busy}>
331 <WizardHeader title={desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"}>
343332 <WizardSteps
344 steps={SETUP_STEPS.map((item, index) => ({ ...item, disabled: index > stepIndex }))}
345 currentStep={step}
346 disabled={busy}
347 onStepSelect={(next) => {
348 if (next !== step) changeStep(next);
333 steps={SETUP_STEPS.map((item) => item.label)}
334 currentStep={stepIndex}
335 isStepDisabled={(index) => busy || index > stepIndex}
336 onStepChange={(index) => {
337 const next = SETUP_STEPS[index];
338 if (next && next.id !== step) changeStep(next.id);
349339 }}
350340 />
351 </DialogHeader>
352 <DialogPanel>
341 </WizardHeader>
342 <WizardPanel>
353343 <div className="space-y-4 text-sm">
354344 <div className="space-y-2" aria-live="polite">
355345 <h3 className="flex items-center gap-2 font-medium">{title}</h3>
......@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({
479469 </details>
480470 ) : null}
481471 </div>
482 </DialogPanel>
483 <DialogFooter variant="bare">
472 </WizardPanel>
473 <WizardFooter>
484474 {step !== "access" ? (
485475 <Button variant="ghost" disabled={busy} onClick={() => changeStep("access")}>
486476 Back
......@@ -566,8 +556,8 @@ export function SnapShotSetupDialog({
566556 {busy ? "Saving…" : shortcutChanged ? "Save and finish" : "Done"}
567557 </Button>
568558 ) : null}
569 </DialogFooter>
570 </DialogPopup>
559 </WizardFooter>
560 </WizardPopup>
571561 </Dialog>
572562 );
573563}
apps/web/src/components/ui/wizard-steps.tsx deleted-56
......@@ -1,56 +0,0 @@
1import { CheckIcon } from "lucide-react";
2import { cn } from "~/lib/utils";
3
4export function WizardSteps<Step extends string>({
5 steps,
6 currentStep,
7 disabled = false,
8 onStepSelect,
9}: {
10 steps: readonly { id: Step; label: string; disabled?: boolean }[];
11 currentStep: Step;
12 disabled?: boolean;
13 onStepSelect: (step: Step) => void;
14}) {
15 const currentIndex = steps.findIndex((step) => step.id === currentStep);
16 return (
17 <ol className="grid auto-cols-fr grid-flow-col gap-2" aria-label="Setup steps">
18 {steps.map((step, index) => (
19 <li key={step.id} className="min-w-0">
20 <button
21 type="button"
22 disabled={disabled || step.disabled}
23 aria-current={index === currentIndex ? "step" : undefined}
24 className={cn(
25 "grid w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-2 rounded-lg border px-3 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default",
26 index === currentIndex
27 ? "border-primary bg-primary/10 ring-1 ring-primary/25"
28 : index < currentIndex
29 ? "border-border bg-background"
30 : "border-border bg-muted/40",
31 )}
32 onClick={() => onStepSelect(step.id)}
33 >
34 <span
35 className={cn(
36 "row-span-2 mt-0.5 grid size-4 place-items-center rounded-full border",
37 index < currentIndex
38 ? "border-primary bg-primary text-primary-foreground"
39 : index === currentIndex
40 ? "border-primary bg-background"
41 : "border-muted-foreground/35 bg-background",
42 )}
43 aria-hidden
44 >
45 {index < currentIndex ? <CheckIcon className="size-3" /> : null}
46 </span>
47 <span className="text-[10px] font-medium uppercase text-muted-foreground">
48 Step {index + 1}
49 </span>
50 <span className="truncate text-xs font-semibold text-foreground">{step.label}</span>
51 </button>
52 </li>
53 ))}
54 </ol>
55 );
56}
apps/web/src/components/ui/wizard.tsx+65-9
......@@ -1,19 +1,75 @@
11import { CheckIcon } from "lucide-react";
2import type { ComponentProps } from "react";
2import type { ComponentProps, ReactNode } from "react";
33
44import { cn } from "../../lib/utils";
55import { AnimatedHeight } from "../AnimatedHeight";
6import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog";
7
8/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */
9export function WizardPopup({
10 children,
11 ...props
12}: Omit<ComponentProps<typeof DialogPopup>, "className" | "style">) {
13 return (
14 <DialogPopup {...props} className="max-w-xl overflow-x-hidden overflow-y-auto">
15 <div className="flex min-h-0 flex-col">{children}</div>
16 </DialogPopup>
17 );
18}
19
20export function WizardHeader({
21 title,
22 description,
23 identity,
24 children,
25}: {
26 readonly title: ReactNode;
27 readonly description?: ReactNode;
28 /** Optional branding shown in place of the visible title. The title remains accessible. */
29 readonly identity?: ReactNode;
30 readonly children?: ReactNode;
31}) {
32 return (
33 <DialogHeader>
34 <DialogTitle className={identity ? "sr-only" : undefined}>{title}</DialogTitle>
35 {identity}
36 {description ? <DialogDescription>{description}</DialogDescription> : null}
37 {children}
38 </DialogHeader>
39 );
40}
41
42export function WizardFooter({
43 children,
44 leading,
45}: {
46 readonly children: ReactNode;
47 readonly leading?: ReactNode;
48}) {
49 return (
50 <DialogFooter variant="bare" className={leading ? "sm:justify-between" : undefined}>
51 {leading}
52 {leading ? (
53 <div className="flex flex-col-reverse gap-2 sm:flex-row">{children}</div>
54 ) : (
55 children
56 )}
57 </DialogFooter>
58 );
59}
660
761export function WizardSteps({
862 steps,
963 currentStep,
1064 summaries,
65 showSummaries = false,
1166 onStepChange,
1267 isStepDisabled,
1368}: {
1469 readonly steps: readonly string[];
1570 readonly currentStep: number;
1671 readonly summaries?: readonly (string | null)[];
72 readonly showSummaries?: boolean;
1773 readonly isStepDisabled?: (step: number) => boolean;
1874 readonly onStepChange?: (step: number) => void;
1975}) {
......@@ -61,6 +117,9 @@ export function WizardSteps({
61117 )}
62118 >
63119 {step}
120 {showSummaries && index < currentStep && summaries?.[index]
121 ? `: ${summaries[index]}`
122 : null}
64123 </span>
65124 </Step>
66125 </li>
......@@ -70,19 +129,16 @@ export function WizardSteps({
70129}
71130
72131export function WizardPanel({
73 className,
74132 children,
75133 holdHeight = false,
76 ...props
77}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) {
134}: {
135 readonly children: ReactNode;
136 readonly holdHeight?: boolean;
137}) {
78138 return (
79139 <div
80140 data-slot="dialog-panel"
81 className={cn(
82 "space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
83 className,
84 )}
85 {...props}
141 className="min-w-0 space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5"
86142 >
87143 <AnimatedHeight holdHeight={holdHeight}>{children}</AnimatedHeight>
88144 </div>