Labels truly half (or less) working

This commit is contained in:
Ryan Pandya 2024-11-14 23:08:47 -08:00
parent 50a89d4104
commit 53ca8614b8
25 changed files with 1502 additions and 237 deletions

View File

@ -0,0 +1,10 @@
import React from "react";
import LabelsView from "@/components/dashboard/labels/LabelsView";
export default async function LabelsPage() {
return (
<div>
<LabelsView />
</div>
);
}

View File

@ -1,15 +0,0 @@
import AllTagsView from "@/components/dashboard/tags/AllTagsView";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
export default async function TagsPage() {
const allTags = (await api.tags.list()).tags;
return (
<div className="space-y-4 rounded-md border bg-background p-4">
<span className="text-2xl">All Tags</span>
<Separator />
<AllTagsView initialData={allTags} />
</div>
);
}

View File

@ -4,7 +4,7 @@ import { getServerAuthSession } from "@/server/auth";
export default async function Home() {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard/settings");
redirect("/dashboard/today");
} else {
redirect("/signin");
}

View File

@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { TRPCClientError } from "@trpc/client";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { HexColorPicker } from "react-colorful";
import { zLabelSchema } from "@lifetracker/shared/types/labels";
type CreateLabelSchema = z.infer<typeof zLabelSchema>;
export default function AddLabelDialog({
children,
}: {
children?: React.ReactNode;
}) {
const apiUtils = api.useUtils();
const [isOpen, onOpenChange] = useState(false);
const form = useForm<CreateLabelSchema>({
resolver: zodResolver(zLabelSchema),
defaultValues: {
code: -1,
name: "",
description: "",
color: "#000022",
},
});
const { mutate, isPending } = api.labels.createLabel.useMutation({
onSuccess: () => {
toast({
description: "Label created successfully",
});
onOpenChange(false);
},
onError: (error) => {
if (error instanceof TRPCClientError) {
toast({
variant: "destructive",
description: error.message,
});
} else {
toast({
variant: "destructive",
description: "Failed to create user",
});
}
},
});
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Label</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => mutate(data))}>
<div className="flex w-full flex-col space-y-2">
<div className="flex">
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Name"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Description"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton
type="submit"
loading={isPending}
disabled={isPending}
>
Create
</ActionButton>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog >
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { ActionButtonWithTooltip } from "@/components/ui/action-button";
import { ButtonWithTooltip } from "@/components/ui/button";
import LoadingSpinner from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { Check, KeyRound, Pencil, Trash, FilePlus, X } from "lucide-react";
import { useSession } from "next-auth/react";
import AddLabelDialog from "./AddLabelDialog";
export default function LabelsView() {
const { data: session } = useSession();
const { data: labels } = api.labels.list.useQuery();
const LabelsTable = ({ labels }) => (
<Table>
<TableHeader className="bg-gray-200">
<TableHead>Code</TableHead>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Entries With Label</TableHead>
<TableHead>Actions</TableHead>
</TableHeader>
<TableBody>
{labels.labels.map((l) => (
<TableRow key={l.id}>
<TableCell className="py-1">{l.code}</TableCell>
<TableCell className="py-1">{l.name}</TableCell>
<TableCell className="py-1">{l.description}</TableCell>
<TableCell className="py-1">
{labelStats[l.id].numEntries}
</TableCell>
<TableCell className="flex gap-1 py-1">
<ActionButtonWithTooltip
tooltip="Delete label"
variant="outline"
onClick={() => deleteLabel({ labelId: l.id })}
loading={false}
>
<Trash size={16} color="red" />
</ActionButtonWithTooltip>
<EditLabelDialog labelId={l.id} >
<ButtonWithTooltip
tooltip="Edit"
variant="outline"
>
<Pencil size={16} color="red" />
</ButtonWithTooltip>
</EditLabelDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
return (
<>
<div className="mb-2 flex items-center justify-between text-xl font-medium">
<span>All Labels</span>
<AddLabelDialog>
<ButtonWithTooltip tooltip="Create Label" variant="outline">
<FilePlus size={16} />
</ButtonWithTooltip>
</AddLabelDialog>
</div>
{labels === undefined ? (
<LoadingSpinner />
) : (
<LabelsTable labels={labels} />
)}
</>
);
}

View File

@ -39,14 +39,9 @@ export default async function Sidebar() {
},
...searchItem,
{
name: "Tags",
name: "Labels",
icon: <Tag size={18} />,
path: "/dashboard/tags",
},
{
name: "Archive",
icon: <Archive size={18} />,
path: "/dashboard/archive",
path: "/dashboard/labels",
},
];

View File

@ -1,210 +0,0 @@
"use client";
import React from "react";
import { ActionButton } from "@/components/ui/action-button";
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import InfoTooltip from "@/components/ui/info-tooltip";
import { Separator } from "@/components/ui/separator";
import { Toggle } from "@/components/ui/toggle";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { ArrowDownAZ, Combine } from "lucide-react";
import type { ZGetTagResponse } from "@hoarder/shared/types/tags";
import { useDeleteUnusedTags } from "@hoarder/shared-react/hooks/tags";
import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog";
import { TagPill } from "./TagPill";
function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) {
const { mutate, isPending } = useDeleteUnusedTags({
onSuccess: () => {
toast({
description: `Deleted all ${numUnusedTags} unused tags`,
});
},
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
});
return (
<ActionConfirmingDialog
title="Delete all unused tags?"
description={`Are you sure you want to delete the ${numUnusedTags} unused tags?`}
actionButton={() => (
<ActionButton
variant="destructive"
loading={isPending}
onClick={() => mutate()}
>
DELETE THEM ALL
</ActionButton>
)}
>
<Button variant="destructive" disabled={numUnusedTags == 0}>
Delete All Unused Tags
</Button>
</ActionConfirmingDialog>
);
}
const byUsageSorter = (a: ZGetTagResponse, b: ZGetTagResponse) => {
// Sort by name if the usage is the same to get a stable result
if (b.numBookmarks == a.numBookmarks) {
return byNameSorter(a, b);
}
return b.numBookmarks - a.numBookmarks;
};
const byNameSorter = (a: ZGetTagResponse, b: ZGetTagResponse) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
export default function AllTagsView({
initialData,
}: {
initialData: ZGetTagResponse[];
}) {
interface Tag {
id: string;
name: string;
}
const [draggingEnabled, setDraggingEnabled] = React.useState(false);
const [sortByName, setSortByName] = React.useState(false);
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const [selectedTag, setSelectedTag] = React.useState<Tag | null>(null);
const handleOpenDialog = (tag: Tag) => {
setSelectedTag(tag);
setIsDialogOpen(true);
};
function toggleSortByName(): void {
setSortByName(!sortByName);
}
function toggleDraggingEnabled(): void {
setDraggingEnabled(!draggingEnabled);
}
const { data } = api.tags.list.useQuery(undefined, {
initialData: { tags: initialData },
});
// Sort tags by usage desc
const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter);
const humanTags = allTags.filter(
(t) => (t.numBookmarksByAttachedType.human ?? 0) > 0,
);
const aiTags = allTags.filter(
(t) => (t.numBookmarksByAttachedType.ai ?? 0) > 0,
);
const emptyTags = allTags.filter((t) => t.numBookmarks === 0);
const tagsToPill = (tags: typeof allTags) => {
let tagPill;
if (tags.length) {
tagPill = (
<div className="flex flex-wrap gap-3">
{tags.map((t) => (
<TagPill
key={t.id}
id={t.id}
name={t.name}
count={t.numBookmarks}
isDraggable={draggingEnabled}
onOpenDialog={handleOpenDialog}
/>
))}
</div>
);
} else {
tagPill = "No Tags";
}
return tagPill;
};
return (
<>
{selectedTag && (
<DeleteTagConfirmationDialog
tag={selectedTag}
open={isDialogOpen}
setOpen={(o) => {
if (!o) {
setSelectedTag(null);
}
setIsDialogOpen(o);
}}
/>
)}
<div className="flex justify-end gap-x-2">
<Toggle
variant="outline"
aria-label="Toggle bold"
pressed={draggingEnabled}
onPressedChange={toggleDraggingEnabled}
>
<Combine className="mr-2 size-4" />
Drag & Drop Merging
<InfoTooltip size={15} className="my-auto ml-2" variant="explain">
<p>Drag and drop tags on each other to merge them</p>
</InfoTooltip>
</Toggle>
<Toggle
variant="outline"
aria-label="Toggle bold"
pressed={sortByName}
onPressedChange={toggleSortByName}
>
<ArrowDownAZ className="mr-2 size-4" /> Sort by Name
</Toggle>
</div>
<span className="flex items-center gap-2">
<p className="text-lg">Your Tags</p>
<InfoTooltip size={15} className="my-auto" variant="explain">
<p>Tags that were attached at least once by you</p>
</InfoTooltip>
</span>
{tagsToPill(humanTags)}
<Separator />
<span className="flex items-center gap-2">
<p className="text-lg">AI Tags</p>
<InfoTooltip size={15} className="my-auto" variant="explain">
<p>Tags that were only attached automatically (by AI)</p>
</InfoTooltip>
</span>
{tagsToPill(aiTags)}
<Separator />
<span className="flex items-center gap-2">
<p className="text-lg">Unused Tags</p>
<InfoTooltip size={15} className="my-auto" variant="explain">
<p>Tags that are not attached to any bookmarks</p>
</InfoTooltip>
</span>
<Collapsible>
<div className="space-x-1 pb-2">
<CollapsibleTrigger asChild>
<Button variant="secondary" disabled={emptyTags.length == 0}>
{emptyTags.length > 0
? `Show ${emptyTags.length} unused tags`
: "You don't have any unused tags"}
</Button>
</CollapsibleTrigger>
{emptyTags.length > 0 && (
<DeleteAllUnusedTags numUnusedTags={emptyTags.length} />
)}
</div>
<CollapsibleContent>{tagsToPill(emptyTags)}</CollapsibleContent>
</Collapsible>
</>
);
}

View File

@ -18,11 +18,11 @@
"@auth/drizzle-adapter": "^1.4.2",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.3.4",
"@lifetracker/db": "workspace:^",
"@lifetracker/shared": "workspace:^0.1.0",
"@lifetracker/shared-react": "workspace:^0.1.0",
"@lifetracker/trpc": "workspace:^",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
@ -58,6 +58,7 @@
"next-themes": "^0.3.0",
"prettier": "^3.2.5",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-dropzone": "^14.2.3",

View File

@ -0,0 +1,11 @@
CREATE TABLE `label` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`createdAt` integer NOT NULL,
`userId` text NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `labels_name_idx` ON `label` (`name`);--> statement-breakpoint
CREATE INDEX `labels_userId_idx` ON `label` (`userId`);--> statement-breakpoint
CREATE UNIQUE INDEX `label_userId_name_unique` ON `label` (`userId`,`name`);

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS `labels_name_idx`;--> statement-breakpoint
DROP INDEX IF EXISTS `labels_userId_idx`;--> statement-breakpoint
ALTER TABLE `label` ADD `description` text;--> statement-breakpoint
ALTER TABLE `label` ADD `color` text DEFAULT '#000000';

View File

@ -0,0 +1,497 @@
{
"version": "6",
"dialect": "sqlite",
"id": "06e873b5-ab39-469c-9437-bb1cf4bc60e8",
"prevId": "bcdf4680-869f-4b4a-9690-1bd45a196387",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {}
},
"apiKey": {
"name": "apiKey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyId": {
"name": "keyId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyHash": {
"name": "keyHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"apiKey_keyId_unique": {
"name": "apiKey_keyId_unique",
"columns": [
"keyId"
],
"isUnique": true
},
"apiKey_name_userId_unique": {
"name": "apiKey_name_userId_unique",
"columns": [
"name",
"userId"
],
"isUnique": true
}
},
"foreignKeys": {
"apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"config": {
"name": "config",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"day": {
"name": "day",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"mood": {
"name": "mood",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"day_date_unique": {
"name": "day_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"label": {
"name": "label",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"labels_name_idx": {
"name": "labels_name_idx",
"columns": [
"name"
],
"isUnique": false
},
"labels_userId_idx": {
"name": "labels_userId_idx",
"columns": [
"userId"
],
"isUnique": false
},
"label_userId_name_unique": {
"name": "label_userId_name_unique",
"columns": [
"userId",
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"label_userId_user_id_fk": {
"name": "label_userId_user_id_fk",
"tableFrom": "label",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'user'"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
],
"name": "verificationToken_identifier_token_pk"
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,498 @@
{
"version": "6",
"dialect": "sqlite",
"id": "22c67302-ebcd-4ad9-8393-feea41c17455",
"prevId": "06e873b5-ab39-469c-9437-bb1cf4bc60e8",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {}
},
"apiKey": {
"name": "apiKey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyId": {
"name": "keyId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyHash": {
"name": "keyHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"apiKey_keyId_unique": {
"name": "apiKey_keyId_unique",
"columns": [
"keyId"
],
"isUnique": true
},
"apiKey_name_userId_unique": {
"name": "apiKey_name_userId_unique",
"columns": [
"name",
"userId"
],
"isUnique": true
}
},
"foreignKeys": {
"apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"config": {
"name": "config",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"day": {
"name": "day",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"mood": {
"name": "mood",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"day_date_unique": {
"name": "day_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"label": {
"name": "label",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'#000000'"
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"label_userId_name_unique": {
"name": "label_userId_name_unique",
"columns": [
"userId",
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"label_userId_user_id_fk": {
"name": "label_userId_user_id_fk",
"tableFrom": "label",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'user'"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
],
"name": "verificationToken_identifier_token_pk"
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -36,6 +36,20 @@
"when": 1731637559763,
"tag": "0004_sad_sally_floyd",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1731648881226,
"tag": "0005_ambitious_famine",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1731649046343,
"tag": "0006_milky_hellion",
"breakpoints": true
}
]
}

View File

@ -105,6 +105,26 @@ export const days = sqliteTable("day", {
comment: text("comment").notNull(),
});
export const labels = sqliteTable(
"label",
{
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
createdAt: createdAtField(),
name: text("name").notNull(),
description: text("description"),
color: text("color").default("#000000"),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(lb) => ({
uniq: unique().on(lb.userId, lb.name)
}),
);
export const config = sqliteTable("config", {
key: text("key").notNull().primaryKey(),
value: text("value").notNull(),
@ -118,3 +138,17 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
references: [users.id],
}),
}));
export const userRelations = relations(users, ({ many }) => ({
labels: many(labels),
}));
export const labelsRelations = relations(
labels,
({ many, one }) => ({
user: one(users, {
fields: [labels.userId],
references: [users.id],
}),
}),
);

View File

@ -0,0 +1,24 @@
import { z } from "zod";
export const zLabelSchema = z.object({
id: z.string(),
code: z.coerce.number(),
name: z.string(),
color: z.string().default("#000000"),
description: z.string().optional(),
});
export type ZLabels = z.infer<typeof zLabelSchema>;
export const zGetLabelResponseSchema = z.object({
id: z.string(),
code: z.number(),
name: z.string(),
numEntries: z.number(),
});
export type ZGetLabelResponse = z.infer<typeof zGetLabelResponseSchema>;
export const zUpdateLabelRequestSchema = z.object({
labelId: z.string(),
code: z.number(),
name: z.string().optional(),
});

View File

@ -3,11 +3,13 @@ import { router } from "../index";
import { usersAppRouter } from "./users";
import { apiKeysAppRouter } from "./apiKeys";
import { adminAppRouter } from "./admin";
import { labelsAppRouter } from "./labels";
export const appRouter = router({
users: usersAppRouter,
apiKeys: apiKeysAppRouter,
admin: adminAppRouter,
labels: labelsAppRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,146 @@
import { experimental_trpcMiddleware, TRPCError } from "@trpc/server";
import { and, desc, eq, inArray, notExists } from "drizzle-orm";
import { z } from "zod";
import { SqliteError } from "@lifetracker/db";
import { labels } from "@lifetracker/db/schema";
import {
zLabelSchema,
zGetLabelResponseSchema,
} from "@lifetracker/shared/types/labels";
import type { Context } from "../index";
import { authedProcedure, router } from "../index";
function conditionFromInput(input: { labelId: string }, userId: string) {
return and(eq(labels.id, input.labelId), eq(labels.userId, userId));
}
async function createLabel(
input: z.infer<typeof zLabelSchema>,
ctx: Context,
) {
return ctx.db.transaction(async (trx) => {
try {
const result = await trx
.insert(labels)
.values({
name: input.name,
code: input.code,
description: input.description,
color: input.color,
userId: ctx.user!.id,
})
.returning({
id: labels.id,
name: labels.name,
code: labels.code,
description: labels.description,
color: labels.color,
});
return result[0];
} catch (e) {
if (e instanceof SqliteError) {
if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Line 48 trpc routers labels.ts",
});
}
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
});
}
export const labelsAppRouter = router({
list: authedProcedure
.output(
z.object({
labels: z.array(zGetLabelResponseSchema),
}),
)
.query(async ({ ctx }) => {
const res = await ctx.db
.select({
id: labels.id,
name: labels.name,
color: labels.color,
description: labels.description,
})
.from(labels)
.where(eq(labels.userId, ctx.user.id));
return {
labels: res.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
description: r.description,
numEntries: 420,
})),
};
}),
get: authedProcedure
.input(
z.object({
labelId: z.string(),
}),
)
.output(zGetLabelResponseSchema)
.query(async ({ input, ctx }) => {
const res = await ctx.db
.select({
id: labels.id,
name: labels.name
})
.from(labels)
.where(
and(
conditionFromInput(input, ctx.user.id),
eq(labels.userId, ctx.user.id),
),
);
if (res.length == 0) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const numEntriesWithLabel = res.reduce<
Record<ZLabeledByEnum, number>
>(
(acc, curr) => {
if (curr.labeledBy) {
acc[curr.labeledBy]++;
}
return acc;
},
{ ai: 0, human: 0 },
);
return {
id: res[0].id,
name: res[0].name,
numEntries: 420
};
}),
createLabel: authedProcedure
.input(zLabelSchema)
.output(
z.object({
id: z.string(),
code: z.number(),
name: z.string(),
color: z.string().default("#000000"),
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
console.log("Just started creating a label");
return createLabel(input, ctx);
}),
});

20
pnpm-lock.yaml generated
View File

@ -256,6 +256,9 @@ importers:
react:
specifier: ^18.2.0
version: 18.3.1
react-colorful:
specifier: ^5.6.1
version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
@ -9707,6 +9710,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-colorful@5.6.1:
resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-dev-utils@12.0.1:
resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==}
engines: {node: '>=14'}
@ -19684,7 +19693,7 @@ snapshots:
eslint: 8.57.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.33.2(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@ -19738,7 +19747,7 @@ snapshots:
enhanced-resolve: 5.15.0
eslint: 8.57.0
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
fast-glob: 3.3.1
get-tsconfig: 4.7.2
is-core-module: 2.13.1
@ -19777,7 +19786,7 @@ snapshots:
eslint: 8.57.0
ignore: 5.3.1
eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
dependencies:
array-includes: 3.1.7
array.prototype.findlastindex: 1.2.3
@ -24083,6 +24092,11 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-colorful@5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-dev-utils@12.0.1(eslint@8.57.0)(typescript@5.3.3)(webpack@5.95.0):
dependencies:
'@babel/code-frame': 7.22.13