Merge pull request #4 from hex248/development

Login/register modal instead of /login
This commit is contained in:
Oliver Bryan
2026-01-28 10:24:30 +00:00
committed by GitHub
16 changed files with 1062 additions and 83 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "User" ALTER COLUMN "iconPreference" SET DEFAULT 'pixel';

View File

@@ -0,0 +1,930 @@
{
"id": "c4f3042a-375d-4d17-b836-3d7816b26519",
"prevId": "95c0fe99-9c32-4baf-a635-3be4c92b90de",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Issue": {
"name": "Issue",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Issue_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'Task'"
},
"status": {
"name": "status",
"type": "varchar(24)",
"primaryKey": false,
"notNull": true,
"default": "'TO DO'"
},
"title": {
"name": "title",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sprintId": {
"name": "sprintId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"unique_project_issue_number": {
"name": "unique_project_issue_number",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "number",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"Issue_projectId_Project_id_fk": {
"name": "Issue_projectId_Project_id_fk",
"tableFrom": "Issue",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_creatorId_User_id_fk": {
"name": "Issue_creatorId_User_id_fk",
"tableFrom": "Issue",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_sprintId_Sprint_id_fk": {
"name": "Issue_sprintId_Sprint_id_fk",
"tableFrom": "Issue",
"tableTo": "Sprint",
"columnsFrom": [
"sprintId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueAssignee": {
"name": "IssueAssignee",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueAssignee_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"assignedAt": {
"name": "assignedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"unique_issue_user": {
"name": "unique_issue_user",
"columns": [
{
"expression": "issueId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"IssueAssignee_issueId_Issue_id_fk": {
"name": "IssueAssignee_issueId_Issue_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueAssignee_userId_User_id_fk": {
"name": "IssueAssignee_userId_User_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueComment": {
"name": "IssueComment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueComment_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"IssueComment_issueId_Issue_id_fk": {
"name": "IssueComment_issueId_Issue_id_fk",
"tableFrom": "IssueComment",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueComment_userId_User_id_fk": {
"name": "IssueComment_userId_User_id_fk",
"tableFrom": "IssueComment",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Organisation": {
"name": "Organisation",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Organisation_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(1024)",
"primaryKey": false,
"notNull": false
},
"slug": {
"name": "slug",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"iconURL": {
"name": "iconURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"statuses": {
"name": "statuses",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json"
},
"issueTypes": {
"name": "issueTypes",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json"
},
"features": {
"name": "features",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"Organisation_slug_unique": {
"name": "Organisation_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.OrganisationMember": {
"name": "OrganisationMember",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "OrganisationMember_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"OrganisationMember_organisationId_Organisation_id_fk": {
"name": "OrganisationMember_organisationId_Organisation_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"OrganisationMember_userId_User_id_fk": {
"name": "OrganisationMember_userId_User_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Project": {
"name": "Project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Project_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"key": {
"name": "key",
"type": "varchar(4)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Project_organisationId_Organisation_id_fk": {
"name": "Project_organisationId_Organisation_id_fk",
"tableFrom": "Project",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Project_creatorId_User_id_fk": {
"name": "Project_creatorId_User_id_fk",
"tableFrom": "Project",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Session": {
"name": "Session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Session_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"csrfToken": {
"name": "csrfToken",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"expiresAt": {
"name": "expiresAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Session_userId_User_id_fk": {
"name": "Session_userId_User_id_fk",
"tableFrom": "Session",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Sprint": {
"name": "Sprint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Sprint_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"default": "'#a1a1a1'"
},
"startDate": {
"name": "startDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"endDate": {
"name": "endDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Sprint_projectId_Project_id_fk": {
"name": "Sprint_projectId_Project_id_fk",
"tableFrom": "Sprint",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.TimedSession": {
"name": "TimedSession",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "TimedSession_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"timestamps": {
"name": "timestamps",
"type": "timestamp[]",
"primaryKey": false,
"notNull": true
},
"endedAt": {
"name": "endedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"TimedSession_userId_User_id_fk": {
"name": "TimedSession_userId_User_id_fk",
"tableFrom": "TimedSession",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"TimedSession_issueId_Issue_id_fk": {
"name": "TimedSession_issueId_Issue_id_fk",
"tableFrom": "TimedSession",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "User_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"avatarURL": {
"name": "avatarURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"iconPreference": {
"name": "iconPreference",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true,
"default": "'pixel'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"User_username_unique": {
"name": "User_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -176,6 +176,13 @@
"when": 1769295524021,
"tag": "0024_military_stryfe",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1769549697892,
"tag": "0025_sharp_quicksilver",
"breakpoints": true
}
]
}

View File

@@ -24,7 +24,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [iconPreference, setIconPreference] = useState<IconStyle>("lucide");
const [iconPreference, setIconPreference] = useState<IconStyle>("pixel");
const [error, setError] = useState("");
const [submitAttempted, setSubmitAttempted] = useState(false);
@@ -34,7 +34,7 @@ function Account({ trigger }: { trigger?: ReactNode }) {
setName(currentUser.name);
setUsername(currentUser.username);
setAvatarUrl(currentUser.avatarURL || null);
setIconPreference((currentUser.iconPreference as IconStyle) ?? "lucide");
setIconPreference((currentUser.iconPreference as IconStyle) ?? "pixel");
setPassword("");
setError("");
@@ -136,18 +136,18 @@ function Account({ trigger }: { trigger?: ReactNode }) {
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" align="start">
<SelectItem value="lucide">
<div className="flex items-center gap-2">
<Icon icon="sun" iconStyle="lucide" size={16} />
Lucide
</div>
</SelectItem>
<SelectItem value="pixel">
<div className="flex items-center gap-2">
<Icon icon="sun" iconStyle="pixel" size={16} />
Pixel
</div>
</SelectItem>
<SelectItem value="lucide">
<div className="flex items-center gap-2">
<Icon icon="sun" iconStyle="lucide" size={16} />
Lucide
</div>
</SelectItem>
<SelectItem value="phosphor">
<div className="flex items-center gap-2">
<Icon icon="sun" iconStyle="phosphor" size={16} />

View File

@@ -1,6 +1,6 @@
import { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH } from "@sprint/shared";
import { type FormEvent, useMemo, useState } from "react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useAuthenticatedSession } from "@/components/session-provider";
@@ -57,6 +57,10 @@ 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);
useEffect(() => {
if (!status && defaultStatus) setStatus(defaultStatus);
if (!type && defaultType) setType(defaultType);
}, [defaultStatus, defaultType, status, type]);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

View File

@@ -19,15 +19,18 @@ const DEMO_USERS = [
{ name: "User 2", username: "u2", password: "a" },
];
export default function LogInForm() {
export default function LogInForm({
showWarning,
setShowWarning,
}: {
showWarning: boolean;
setShowWarning: (value: boolean) => void;
}) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setUser } = useSession();
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
const [showWarning, setShowWarning] = useState(() => {
return localStorage.getItem("hide-under-construction") !== "true";
});
const [mode, setMode] = useState<"login" | "register">("login");
@@ -143,7 +146,7 @@ export default function LogInForm() {
<>
{/* under construction warning */}
{showWarning && (
<div className="relative flex flex-col border p-4 items-center border-border/50 bg-border/10 gap-2 max-w-lg">
<div className="relative flex flex-col items-center gap-2 max-w-lg">
<IconButton
size="md"
className="absolute top-2 right-2"
@@ -168,7 +171,7 @@ export default function LogInForm() {
<DialogTrigger className="text-primary hover:text-personality cursor-pointer mt-2">
Login Details
</DialogTrigger>
<DialogContent className="w-xs" showCloseButton={false}>
<DialogContent showCloseButton={false}>
<DialogTitle className="sr-only">Demo Login Credentials</DialogTitle>
<div className="grid grid-cols-2 gap-4">
{DEMO_USERS.map((user) => (
@@ -208,7 +211,7 @@ export default function LogInForm() {
<form onSubmit={handleSubmit}>
<div
className={cn(
"relative flex flex-col gap-2 items-center border p-6 pb-4",
"relative flex flex-col gap-2 items-center p-4 pb-2",
error !== "" && "border-destructive",
)}
>

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import LogInForm from "@/components/login-form";
import { useSession } from "@/components/session-provider";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
interface LoginModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
dismissible?: boolean;
}
export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }: LoginModalProps) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user, isLoading } = useSession();
const [hasRedirected, setHasRedirected] = useState(false);
const [showWarning, setShowWarning] = useState(() => {
return localStorage.getItem("hide-under-construction") !== "true";
});
useEffect(() => {
if (open && !isLoading && user && !hasRedirected) {
setHasRedirected(true);
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
onSuccess?.();
onOpenChange(false);
}
}, [open, user, isLoading, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]);
useEffect(() => {
if (!open) {
setHasRedirected(false);
}
}, [open]);
const handleOpenChange = (newOpen: boolean) => {
if (!dismissible && !newOpen) {
return;
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false} className={cn("p-0 w-xs py-8", showWarning && "w-md pt-4")}>
<DialogTitle className="sr-only">Log In or Register</DialogTitle>
<LogInForm showWarning={showWarning} setShowWarning={setShowWarning} />
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,7 @@
import type { IssueResponse, OrganisationResponse, ProjectResponse } from "@sprint/shared";
import type { ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
type SelectionContextValue = {
selectedOrganisationId: number | null;
@@ -69,6 +70,8 @@ const updateUrlParams = (updates: {
};
export function SelectionProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const initialParams = useMemo(() => {
const params = new URLSearchParams(window.location.search);
const orgSlug = params.get("o")?.trim().toLowerCase() ?? "";
@@ -153,24 +156,25 @@ export function SelectionProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const allowIssue = window.location.pathname.startsWith("/issues");
const pathname = location.pathname;
const allowParams = pathname.startsWith("/issues") || pathname.startsWith("/timeline");
const updates: {
orgSlug?: string | null;
projectKey?: string | null;
issueNumber?: number | null;
} = {};
if (!params.get("o")) {
if (allowParams && !params.get("o")) {
const storedOrgSlug = readStoredString("selectedOrganisationSlug");
if (storedOrgSlug) updates.orgSlug = storedOrgSlug;
}
if (!params.get("p")) {
if (allowParams && !params.get("p")) {
const storedProjectKey = readStoredString("selectedProjectKey");
if (storedProjectKey) updates.projectKey = storedProjectKey;
}
if (allowIssue && !params.get("i")) {
if (allowParams && !params.get("i")) {
const storedIssueNumber = readStoredId("selectedIssueNumber");
if (storedIssueNumber != null) updates.issueNumber = storedIssueNumber;
}
@@ -178,7 +182,7 @@ export function SelectionProvider({ children }: { children: ReactNode }) {
if (Object.keys(updates).length > 0) {
updateUrlParams(updates);
}
}, []);
}, [location.pathname]);
const value = useMemo<SelectionContextValue>(
() => ({

View File

@@ -1,7 +1,8 @@
import type { UserRecord } from "@sprint/shared";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import Loading from "@/components/loading";
import { LoginModal } from "@/components/login-modal";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
interface SessionContextValue {
@@ -74,15 +75,22 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useSession();
const location = useLocation();
const [loginModalOpen, setLoginModalOpen] = useState(false);
useEffect(() => {
if (!isLoading && !user) {
setLoginModalOpen(true);
} else if (user) {
setLoginModalOpen(false);
}
}, [user, isLoading]);
if (isLoading) {
return <Loading message={"Checking authentication"} />;
}
if (!user) {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} dismissible={false} />;
}
return <>{children}</>;

View File

@@ -48,13 +48,13 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
<OrganisationSelect
noDecoration
triggerClassName="px-1 rounded-full hover:bg-transparent dark:hover:bg-transparent"
triggerClassName="w-8 h-8 ml-1 mr-1 rounded-full hover:bg-transparent dark:hover:bg-transparent"
trigger={
<OrgIcon
name={selectedOrganisation?.Organisation.name ?? ""}
slug={selectedOrganisation?.Organisation.slug ?? ""}
iconURL={selectedOrganisation?.Organisation.iconURL || undefined}
size={7}
size={8}
/>
}
/>

View File

@@ -208,7 +208,7 @@ const icons = {
export type IconName = keyof typeof icons;
export const iconNames = Object.keys(icons) as IconName[];
export const iconStyles = ["lucide", "pixel", "phosphor"] as const;
export const iconStyles = ["pixel", "lucide", "phosphor"] as const;
export type { IconStyle };
export default function Icon({
@@ -227,7 +227,7 @@ export default function Icon({
const resolvedStyle = (iconStyle ??
session?.user?.iconPreference ??
localStorage.getItem("iconPreference") ??
"lucide") as IconStyle;
"pixel") as IconStyle;
const IconComponent = icons[icon]?.[resolvedStyle];
if (localStorage.getItem("iconPreference") !== resolvedStyle)

View File

@@ -11,7 +11,6 @@ import { Toaster } from "@/components/ui/sonner";
import Font from "@/pages/Font";
import Issues from "@/pages/Issues";
import Landing from "@/pages/Landing";
import Login from "@/pages/Login";
import NotFound from "@/pages/NotFound";
import Test from "@/pages/Test";
import Timeline from "@/pages/Timeline";
@@ -21,13 +20,12 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<QueryProvider>
<SessionProvider>
<SelectionProvider>
<BrowserRouter>
<SelectionProvider>
<Routes>
{/* public routes */}
<Route path="/" element={<Landing />} />
<Route path="/font" element={<Font />} />
<Route path="/login" element={<Login />} />
{/* authed routes */}
<Route
@@ -57,10 +55,10 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<Route path="*" element={<NotFound />} />
</Routes>
</SelectionProvider>
</BrowserRouter>
<ActiveTimersOverlay />
<Toaster visibleToasts={1} duration={2000} />
</SelectionProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { LoginModal } from "@/components/login-modal";
import { useSession } from "@/components/session-provider";
import ThemeToggle from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
@@ -24,7 +25,6 @@ const pricingTiers = [
"Email support",
],
cta: "Get started free",
ctaLink: "/login",
highlighted: false,
},
{
@@ -45,7 +45,6 @@ const pricingTiers = [
"Priority email support",
],
cta: "Try pro free for 14 days",
ctaLink: "/login",
highlighted: true,
},
];
@@ -88,6 +87,7 @@ const faqs = [
export default function Landing() {
const { user, isLoading } = useSession();
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("monthly");
const [loginModalOpen, setLoginModalOpen] = useState(false);
return (
<div className="min-h-screen flex flex-col" id="top">
@@ -129,8 +129,8 @@ export default function Landing() {
<Link to="/issues">Open app</Link>
</Button>
) : (
<Button asChild variant="outline" size="sm">
<Link to="/login">Sign in</Link>
<Button variant="outline" size="sm" onClick={() => setLoginModalOpen(true)}>
Sign in
</Button>
)}
</div>
@@ -162,8 +162,8 @@ export default function Landing() {
</Button>
) : (
<>
<Button asChild size="lg" className="text-lg px-8 py-6">
<Link to="/login">Start free trial</Link>
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
Start free trial
</Button>
<Button asChild variant="outline" size="lg" className="text-lg px-8 py-6">
<a href="#pricing">See pricing</a>
@@ -364,14 +364,14 @@ export default function Landing() {
</ul>
<Button
asChild
variant={tier.highlighted ? "default" : "outline"}
className={cn(
"font-700 py-6",
tier.highlighted ? "bg-personality hover:bg-personality/90 text-background" : "",
)}
onClick={() => setLoginModalOpen(true)}
>
<Link to={tier.ctaLink}>{tier.cta}</Link>
{tier.cta}
</Button>
</div>
))}
@@ -455,8 +455,8 @@ export default function Landing() {
<Link to="/issues">Open app</Link>
</Button>
) : (
<Button asChild size="lg" className="text-lg px-8 py-6">
<Link to="/login">Start your free trial</Link>
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
Start your free trial
</Button>
)}
</div>
@@ -467,6 +467,8 @@ export default function Landing() {
</div>
</main>
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
<footer className="flex justify-center gap-2 items-center py-1 border-t">
<span className="font-300 text-lg text-muted-foreground">
Built by{" "}

View File

@@ -1,32 +0,0 @@
import { useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import Loading from "@/components/loading";
import LogInForm from "@/components/login-form";
import { useSession } from "@/components/session-provider";
export default function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user, isLoading } = useSession();
useEffect(() => {
if (!isLoading && user) {
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
}
}, [user, isLoading, navigate, searchParams]);
if (isLoading) {
return <Loading message="Checking authentication" />;
}
if (user) {
return <Loading message="Redirecting" />;
}
return (
<div className="flex flex-col items-center justify-center gap-4 w-full h-[100vh]">
<LogInForm />
</div>
);
}

View File

@@ -49,7 +49,7 @@ export const DEFAULT_FEATURES: Record<string, boolean> = {
sprints: true,
};
export const iconStyles = ["lucide", "pixel", "phosphor"] as const;
export const iconStyles = ["pixel", "lucide", "phosphor"] as const;
export type IconStyle = (typeof iconStyles)[number];
export const User = pgTable("User", {
@@ -58,7 +58,7 @@ export const User = pgTable("User", {
username: varchar({ length: USER_USERNAME_MAX_LENGTH }).notNull().unique(),
passwordHash: varchar({ length: 255 }).notNull(),
avatarURL: varchar({ length: 512 }),
iconPreference: varchar({ length: 10 }).notNull().default("lucide").$type<IconStyle>(),
iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type<IconStyle>(),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
});

View File

@@ -1,8 +1,6 @@
# HIGH PRIORITY
- BUGS:
- org slug and project code should only be added to url on issues/timeline pages. it happens on any page right now
- on the first attempt since page load, pressing the create issue button the default type and status are not loaded
- FEATURES:
- make login/register into a modal that appears atop the landing page
- user preferences
@@ -30,3 +28,4 @@
- figure out if it's possible to remove the "lib/server/..." helpers altogether, and have some sort of dynamic route maker in the shared package
- request logging
- explore payment providers (stripe is the only one i know)
- trial system