mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
Merge branch 'master' into development
This commit is contained in:
@@ -11,6 +11,7 @@ import { Field } from "@/components/ui/field";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { UploadAvatar } from "@/components/upload-avatar";
|
||||
import { useUpdateUser } from "@/lib/query/hooks";
|
||||
import { parseError } from "@/lib/server";
|
||||
@@ -29,6 +30,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
|
||||
const [iconPreference, setIconPreference] = useState<IconStyle>("pixel");
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({});
|
||||
const [error, setError] = useState("");
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
|
||||
@@ -40,6 +42,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
setAvatarUrl(currentUser.avatarURL || null);
|
||||
const effectiveIconStyle = (currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE;
|
||||
setIconPreference(effectiveIconStyle);
|
||||
setPreferences(currentUser.preferences ?? {});
|
||||
|
||||
setPassword("");
|
||||
setError("");
|
||||
@@ -60,6 +63,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
password: password.trim() || undefined,
|
||||
avatarURL,
|
||||
iconPreference,
|
||||
preferences,
|
||||
});
|
||||
setError("");
|
||||
setUser(data);
|
||||
@@ -129,6 +133,18 @@ function Account({ trigger }: { trigger?: ReactNode }) {
|
||||
/>
|
||||
<Label className="text-lg -mt-2">Preferences</Label>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={Boolean(preferences.assignByDefault)}
|
||||
onCheckedChange={(checked) => {
|
||||
setPreferences((prev) => ({ ...prev, assignByDefault: checked }));
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Assign to me by default</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8 justify w-full">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Label className="text-sm">Light/Dark Mode</Label>
|
||||
|
||||
@@ -66,6 +66,14 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
|
||||
const [status, setStatus] = useState<string>(defaultStatus);
|
||||
const [type, setType] = useState<string>(defaultType);
|
||||
|
||||
// set default assignee based on user preference when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && user.preferences?.assignByDefault) {
|
||||
setAssigneeIds([`${user.id}`]);
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status && defaultStatus) setStatus(defaultStatus);
|
||||
if (!type && defaultType) setType(defaultType);
|
||||
|
||||
@@ -2,33 +2,16 @@
|
||||
|
||||
import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { useSession } from "@/components/session-provider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { UploadAvatar } from "@/components/upload-avatar";
|
||||
import { capitalise, cn, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||
|
||||
const DEMO_USERS = [
|
||||
{ name: "User 1", username: "u1", password: "a" },
|
||||
{ name: "User 2", username: "u2", password: "a" },
|
||||
];
|
||||
|
||||
export default function LogInForm({
|
||||
showWarning,
|
||||
setShowWarning,
|
||||
}: {
|
||||
showWarning: boolean;
|
||||
setShowWarning: (value: boolean) => void;
|
||||
}) {
|
||||
export default function LogInForm() {
|
||||
const { setUser, setEmailVerified } = useSession();
|
||||
|
||||
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
|
||||
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
@@ -141,189 +124,124 @@ export default function LogInForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* under construction warning */}
|
||||
{showWarning && (
|
||||
<div className="relative flex flex-col items-center gap-2 max-w-lg">
|
||||
<IconButton
|
||||
size="md"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => {
|
||||
localStorage.setItem("hide-under-construction", "true");
|
||||
setShowWarning(false);
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</IconButton>
|
||||
<Icon icon="alertTriangle" className="w-16 h-16 text-yellow-500" />
|
||||
<div className="text-center text-sm text-muted-foreground font-500">
|
||||
<p>
|
||||
This application is currently under construction. Your data is very likely to be lost at some
|
||||
point.
|
||||
</p>
|
||||
<p className="font-700 underline underline-offset-3 text-foreground/85 decoration-yellow-500 mt-2">
|
||||
It is not recommended for production use.
|
||||
</p>
|
||||
<p className="mt-2">But you're more than welcome to have a look around!</p>
|
||||
<Dialog open={loginDetailsOpen} onOpenChange={setLoginDetailsOpen}>
|
||||
<DialogTrigger className="text-primary hover:text-personality cursor-pointer mt-2">
|
||||
Login Details
|
||||
</DialogTrigger>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">Demo Login Credentials</DialogTitle>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{DEMO_USERS.map((user) => (
|
||||
<button
|
||||
type="button"
|
||||
key={user.username}
|
||||
className="space-y-2 border border-background hover:border-border hover:bg-border/10 cursor-pointer p-2 text-left"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setUsername(user.username);
|
||||
setPassword(user.password);
|
||||
setLoginDetailsOpen(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={user.name} username={user.username} />
|
||||
<span className="font-semibold">{user.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<span className="font-medium text-foreground">Username:</span> {user.username}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium text-foreground">Password:</span> {user.password}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-2 items-center p-4 pb-2",
|
||||
error !== "" && "border-destructive",
|
||||
)}
|
||||
>
|
||||
<span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span>
|
||||
<div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col gap-2 items-center p-4 pb-2",
|
||||
error !== "" && "border-destructive",
|
||||
)}
|
||||
>
|
||||
<span className="text-xl font-basteleur mb-2">{capitalise(mode)}</span>
|
||||
|
||||
<div className={"flex flex-col items-center mb-0"}>
|
||||
{mode === "register" && (
|
||||
<>
|
||||
<UploadAvatar
|
||||
name={name}
|
||||
username={username || undefined}
|
||||
avatarURL={avatarURL}
|
||||
onAvatarUploaded={setAvatarUrl}
|
||||
skipOrgCheck
|
||||
className="mb-2"
|
||||
/>
|
||||
{avatarURL && (
|
||||
<Button
|
||||
variant={"dummy"}
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
setAvatarUrl(null);
|
||||
}}
|
||||
className="-mt-2 mb-2 hover:text-personality"
|
||||
>
|
||||
Remove Avatar
|
||||
</Button>
|
||||
)}
|
||||
<Field
|
||||
label="Full Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_NAME_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_EMAIL_MAX_LENGTH}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_USERNAME_MAX_LENGTH}
|
||||
showCounter={mode === "register"}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
hidden={true}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "login" ? (
|
||||
<div className={"flex flex-col items-center mb-0"}>
|
||||
{mode === "register" && (
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Log in
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I don't have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Register
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
<UploadAvatar
|
||||
name={name}
|
||||
username={username || undefined}
|
||||
avatarURL={avatarURL}
|
||||
onAvatarUploaded={setAvatarUrl}
|
||||
skipOrgCheck
|
||||
className="mb-2"
|
||||
/>
|
||||
{avatarURL && (
|
||||
<Button
|
||||
variant={"dummy"}
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
setAvatarUrl(null);
|
||||
}}
|
||||
className="-mt-2 mb-2 hover:text-personality"
|
||||
>
|
||||
Remove Avatar
|
||||
</Button>
|
||||
)}
|
||||
<Field
|
||||
label="Full Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_NAME_MAX_LENGTH}
|
||||
/>
|
||||
<Field
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_EMAIL_MAX_LENGTH}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
maxLength={USER_USERNAME_MAX_LENGTH}
|
||||
showCounter={mode === "register"}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
|
||||
hidden={true}
|
||||
submitAttempted={submitAttempted}
|
||||
spellcheck={false}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-4">
|
||||
{error !== "" ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Log in
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I don't have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
<>
|
||||
<Button variant={"outline"} type={"submit"}>
|
||||
Register
|
||||
</Button>
|
||||
<Button
|
||||
className="text-xs hover:text-personality p-0"
|
||||
variant={"dummy"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex items-end justify-end w-full text-xs -mb-4">
|
||||
{error !== "" ? (
|
||||
<Label className="text-destructive text-sm">{error}</Label>
|
||||
) : (
|
||||
<Label className="opacity-0 text-sm">a</Label>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,6 @@ export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user, isLoading, emailVerified } = useSession();
|
||||
const [hasRedirected, setHasRedirected] = useState(false);
|
||||
const [showWarning, setShowWarning] = useState(() => {
|
||||
return localStorage.getItem("hide-under-construction") !== "true";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !isLoading && user && emailVerified && !hasRedirected) {
|
||||
@@ -46,9 +43,9 @@ export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8", showWarning && "w-md pt-4")}>
|
||||
<DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8")}>
|
||||
<DialogTitle className="sr-only">Log In or Register</DialogTitle>
|
||||
<LogInForm showWarning={showWarning} setShowWarning={setShowWarning} />
|
||||
<LogInForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -62,17 +62,30 @@ export default function OrgIcon({
|
||||
"flex items-center justify-center rounded-sm overflow-hidden",
|
||||
"text-white font-medium select-none",
|
||||
!iconURL && backgroundClass,
|
||||
`w-${size || 6}`,
|
||||
`h-${size || 6}`,
|
||||
className,
|
||||
)}
|
||||
style={{ width: `calc(var(--spacing) * ${size || 6})`, height: `calc(var(--spacing) * ${size || 6})` }}
|
||||
>
|
||||
{iconURL ? (
|
||||
<img src={iconURL} alt={name} className={`rounded-md object-cover w-${size || 6} h-${size || 6}`} />
|
||||
<img
|
||||
src={iconURL}
|
||||
alt={name}
|
||||
className={`rounded-md object-cover`}
|
||||
style={{
|
||||
width: `calc(var(--spacing) * ${size || 6})`,
|
||||
height: `calc(var(--spacing) * ${size || 6})`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn("flex items-center justify-center", `w-${size || 6}`, `h-${size || 6}`)}>
|
||||
<span className={cn("", textClass)}>{getInitials(name)}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn("flex items-center justify-center", textClass)}
|
||||
style={{
|
||||
width: `calc(var(--spacing) * ${size || 6})`,
|
||||
height: `calc(var(--spacing) * ${size || 6})`,
|
||||
}}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -58,12 +58,12 @@ export default function Landing() {
|
||||
>
|
||||
Pricing
|
||||
</a> */}
|
||||
<a
|
||||
{/* <a
|
||||
href="#faq"
|
||||
className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
|
||||
>
|
||||
FAQ
|
||||
</a>
|
||||
</a> */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
{!isLoading && user ? (
|
||||
|
||||
Reference in New Issue
Block a user