diff --git a/.vscode/settings.json b/.vscode/settings.json index c5b5586..a3b5838 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,10 +2,12 @@ "editor.tabSize": 2, "editor.insertSpaces": true, "workbench.editor.customLabels.patterns": { - "**/route.*": "${dirname}.${extname} (${filename})", - "**/index.*": "${dirname}.${extname} (${filename})" + "{**/index.*,**/route.*,**/layout.*,**/page.*}": "${dirname}.${extname} (${filename})" }, "cSpell.words": [ + "nextjs", + "tailwindcss", + "Topbar", "zustand" ] } diff --git a/app/api/folders/route.ts b/app/api/folders/route.ts index 62b0fe4..2ebfc72 100644 --- a/app/api/folders/route.ts +++ b/app/api/folders/route.ts @@ -7,8 +7,14 @@ export async function POST(request: Request) { const { name, path } = await request.json(); console.debug("Received data:", { name, path }); + // Normalization + let fmtPath = path.replace(/\\/g, "/"); // Replace backslashes with forward + if (!fmtPath.endsWith("/")) { + fmtPath += "/"; // Always add trailing slash + } + const created = await prisma.folder.create({ - data: { name, path }, + data: { name, path: fmtPath }, }); return NextResponse.json(created); @@ -25,9 +31,15 @@ export async function PATCH(request: Request) { try { const { id, name, path } = await request.json(); + // Normalization + let fmtPath = path.replace(/\\/g, "/"); // Replace backslashes with forward + if (!fmtPath.endsWith("/")) { + fmtPath += "/"; // Always add trailing slash + } + const updated = await prisma.folder.update({ where: { id }, - data: { name, path }, + data: { name, path: fmtPath }, }); return NextResponse.json(updated); diff --git a/app/api/items/route.ts b/app/api/items/route.ts index 21c83fc..c8d9cbb 100644 --- a/app/api/items/route.ts +++ b/app/api/items/route.ts @@ -6,8 +6,11 @@ export async function POST(request: Request) { const { path, folderId, name, starred } = await request.json(); console.debug("Received data:", { path, name, starred }); + // Normalization + const fmtPath = path.replace(/\\/g, "/"); // Replace backslashes with forward + const item = await prisma.item.create({ - data: { path, folderId, name, starred }, + data: { path: fmtPath, folderId, name, starred }, }); return NextResponse.json(item); @@ -19,17 +22,14 @@ export async function POST(request: Request) { export async function PATCH(request: Request) { try { - const { - id, - basePathId: folderId, - path, - name, - starred, - } = await request.json(); + const { id, folderId, path, name, starred } = await request.json(); + + // Normalization + const fmtPath = path.replace(/\\/g, "/"); // Replace backslashes with forward const item = await prisma.item.update({ where: { id }, - data: { path, folderId, name, starred }, + data: { path: fmtPath, folderId, name, starred }, }); return NextResponse.json(item); diff --git a/app/components/explorer/imageLoader/index.tsx b/app/components/explorer/imageLoader/index.tsx index 7ffffad..e6c29f7 100644 --- a/app/components/explorer/imageLoader/index.tsx +++ b/app/components/explorer/imageLoader/index.tsx @@ -2,13 +2,17 @@ import { useState, useEffect } from "react"; import { loadImage } from "@/app/lib/imageLoader"; +import { Skeleton } from "@mui/material"; interface Props { path: string; + alt?: string; + width?: number; + height?: number; } export default function ImageLoader(props: Props) { - const { path } = props; + const { path, alt, width, height } = props; const [imageSrc, setImageSrc] = useState(""); useEffect(() => { @@ -24,11 +28,13 @@ export default function ImageLoader(props: Props) { {imageSrc ? ( {"img"} ) : ( -

No image selected

+ )} ); diff --git a/app/components/explorer/index.tsx b/app/components/explorer/index.tsx index ddc9932..6b6a0df 100644 --- a/app/components/explorer/index.tsx +++ b/app/components/explorer/index.tsx @@ -8,17 +8,17 @@ import { getItem } from "@/app/lib/items"; import { Item } from "@/app/lib/db/types"; import { ImageList, ImageListItem } from "@mui/material"; +const size = 250; + export default function Explorer() { const subscribeSelected = useTagTreeState((s) => s.subscribeSelected); - const [id, setId] = useState(0); - const [img, setImg] = useState([]); + const [imagePaths, setImagePaths] = useState([]); useEffect(() => { subscribeSelected(onSelected); }, []); const onSelected = async (id: number) => { - setId(id); const tag = await getTag(id, true, true, true); if (tag === null) return; if (!tag.items) return; @@ -33,19 +33,20 @@ export default function Explorer() { ); items = items.filter((item) => item !== null); - console.log(items); - const paths = items.map((i) => i!.path); - console.log(paths); - setImg(paths); + setImagePaths(paths); } catch (err) {} }; return (
- {img.map((p) => ( - - ))} + + {imagePaths.map((path, index) => ( + + + + ))} +
); } diff --git a/app/components/sidebar/index.tsx b/app/components/sidebar/index.tsx index e282622..1be084f 100644 --- a/app/components/sidebar/index.tsx +++ b/app/components/sidebar/index.tsx @@ -5,6 +5,7 @@ import { Box, Divider, Drawer, + Link, List, ListItem, ListItemButton, @@ -21,6 +22,12 @@ import { useTagTreeState } from "@/app/store/tagTree"; const drawerWidth = 240; const collapsedWidth = 80; +const NavList = [ + { label: "Home", url: "/explorer" }, + { label: "Database", url: "/database" }, + { label: "Docs", url: "/api/docs" }, +]; + export default function Sidebar() { const [open, setOpen] = React.useState(true); const { tagTreeItems, updateTagTree, updateSelectedTagId } = @@ -59,12 +66,9 @@ export default function Sidebar() { - {["Inbox", "Starred", "Send email", "Drafts"].map((text, index) => ( - - - - - + {NavList.map((item, index) => ( + + {item.label} ))} diff --git a/app/components/tag/Button.tsx b/app/components/tag/Button.tsx deleted file mode 100644 index 94cbde3..0000000 --- a/app/components/tag/Button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import React from "react"; -import FormDialog from "./FormDialog"; -import { Button } from "@mui/material"; - -export default function TagButton() { - const [open, setOpen] = React.useState(false); - - const handleClick = () => { - setOpen(true); - }; - - const handleClose = () => { - setOpen(false); - }; - - return ( -
- - -
- ); -} diff --git a/app/database/databaseTable/dbTableHead/index.tsx b/app/database/databaseTable/dbTableHead/index.tsx new file mode 100644 index 0000000..bdf605a --- /dev/null +++ b/app/database/databaseTable/dbTableHead/index.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { + TableHead, + TableRow, + TableCell, + Checkbox, + TableSortLabel, +} from "@mui/material"; +import { Order, Data, HeadCell } from "@/app/database/databaseTable/types"; + +interface Props { + heads: HeadCell[]; + numSelected: number; + order: Order; + orderBy: number; + rowCount: number; + onRequestSort: (event: React.MouseEvent, property: number) => void; + onSelectAllClick: (event: React.ChangeEvent) => void; +} + +export default function DbTableHead(props: Props) { + const { + heads, + onSelectAllClick, + order, + orderBy, + numSelected, + rowCount, + onRequestSort, + } = props; + const createSortHandler = + (property: number) => (event: React.MouseEvent) => { + onRequestSort(event, property); + }; + + return ( + + + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAllClick} + inputProps={{ + "aria-label": "select all desserts", + }} + /> + + {heads.map((headCell) => ( + + + {headCell.label} + + + ))} + + + ); +} diff --git a/app/database/databaseTable/dbTableToolbar/index.tsx b/app/database/databaseTable/dbTableToolbar/index.tsx new file mode 100644 index 0000000..4bab4e1 --- /dev/null +++ b/app/database/databaseTable/dbTableToolbar/index.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { alpha, IconButton, Toolbar, Tooltip, Typography } from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import FilterListIcon from "@mui/icons-material/FilterList"; + +interface Props { + numSelected: number; + title:string; +} + +export default function DbTableToolbar(props: Props) { + const { numSelected ,title } = props; + + return ( + 0 && { + bgcolor: (theme) => + alpha( + theme.palette.primary.main, + theme.palette.action.activatedOpacity + ), + }), + }} + > + {numSelected > 0 ? ( + + {numSelected} selected + + ) : ( + + {title} + + )} + {numSelected > 0 ? ( + + + + + + ) : ( + + + + + + )} + + ); +} diff --git a/app/database/databaseTable/index.tsx b/app/database/databaseTable/index.tsx new file mode 100644 index 0000000..a27a221 --- /dev/null +++ b/app/database/databaseTable/index.tsx @@ -0,0 +1,204 @@ +"use client"; + +import * as React from "react"; +import { + Box, + Paper, + TableContainer, + Table, + TableBody, + TableRow, + TableCell, + Checkbox, + TablePagination, +} from "@mui/material"; +import { Order, Data, HeadCell } from "@/app/database/databaseTable/types"; +import DbTableHead from "./dbTableHead"; +import DbTableToolbar from "./dbTableToolbar"; + +function descendingComparator(a: T, b: T, orderBy: keyof T) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +function getComparator( + order: Order, + orderBy: keyof T +): (a: T, b: T) => number { + return order === "desc" + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +interface Props { + rows: T[]; + heads: HeadCell[]; +} + +export default function DatabaseTable( + props: Props +) { + const { rows, heads } = props; + + const [order, setOrder] = React.useState("asc"); + const [orderBy, setOrderBy] = React.useState( + "name" + ); + const [selected, setSelected] = React.useState([]); + const [page, setPage] = React.useState(0); + const [dense, setDense] = React.useState(true); + const [rowsPerPage, setRowsPerPage] = React.useState(25); + + const handleRequestSort = ( + event: React.MouseEvent, + property: keyof T[number] & string + ) => { + const isAsc = orderBy === property && order === "asc"; + setOrder(isAsc ? "desc" : "asc"); + setOrderBy(property); + }; + + const handleSelectAllClick = (event: React.ChangeEvent) => { + if (event.target.checked) { + const newSelected = rows.map((n) => n[0]); + setSelected(newSelected); + return; + } + setSelected([]); + }; + + const handleClick = (event: React.MouseEvent, id: number) => { + const selectedIndex = selected.indexOf(id); + let newSelected: readonly number[] = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, id); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1) + ); + } + setSelected(newSelected); + }; + + const handleChangePage = (event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = ( + event: React.ChangeEvent + ) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const isSelected = (id: number) => selected.indexOf(id) !== -1; + + // Avoid a layout jump when reaching the last page with empty rows. + const emptyRows = + page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0; + + const visibleRows = React.useMemo( + () => + rows + .slice() + .sort(getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage), + [order, orderBy, page, rows, rowsPerPage] + ); + + return ( + + + + + + + + {visibleRows.map((row, index) => { + const isItemSelected = isSelected(row[0]); + const labelId = `enhanced-table-checkbox-${index}`; + + return ( + handleClick(event, row[0])} + role="checkbox" + aria-checked={isItemSelected} + tabIndex={-1} + key={row[0]} + selected={isItemSelected} + sx={{ cursor: "pointer" }} + > + + + + + {row[1]} + + {row.slice(2).map((cell, index) => ( + + {cell} + + ))} + + ); + })} + {emptyRows > 0 && ( + + + + )} + +
+
+ +
+
+ ); +} diff --git a/app/database/databaseTable/types.ts b/app/database/databaseTable/types.ts new file mode 100644 index 0000000..7a315bc --- /dev/null +++ b/app/database/databaseTable/types.ts @@ -0,0 +1,40 @@ +export interface Data { + id: number; + calories: number; + carbs: number; + fat: number; + name: string; + protein: number; +} + +export function createData( + id: number, + name: string, + calories: number, + fat: number, + carbs: number, + protein: number +): Data { + return { + id, + name, + calories, + fat, + carbs, + protein, + }; +} + +export interface HeadCell { + id: number; + label: string; + align: Align; +} + +export type Align = "right" | "left"; + +export type Order = "asc" | "desc"; + +export type TagRow = [number, string, string, string, string, string]; +export type ItemRow = [number, string, string, string, string, string]; +export type FolderRow = [number, string, string, string, string]; diff --git a/app/database/formDialog/folderFormDialog.tsx b/app/database/formDialog/folderFormDialog.tsx new file mode 100644 index 0000000..37a99df --- /dev/null +++ b/app/database/formDialog/folderFormDialog.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + TextField, +} from "@mui/material"; +import { Folder } from "@/app/lib/db/types"; +import { createFolder } from "@/app/lib/folders"; + +interface Props { + data?: Folder; + open: boolean; + onClose: () => void; +} + +export default function FolderFormDialog(props: Props) { + const { data, open, onClose } = props; + + const handleClose = () => { + onClose(); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const formJson = Object.fromEntries((formData as any).entries()); + console.debug(formJson); + + const name = formJson.name; + const path = formJson.path; + const response = await createFolder(name, path); + + handleClose(); + }; + + return ( + + Folder + + + + + + + + + + + + + + + + ); +} diff --git a/app/database/formDialog/itemFormDialog.tsx b/app/database/formDialog/itemFormDialog.tsx new file mode 100644 index 0000000..b9ee01e --- /dev/null +++ b/app/database/formDialog/itemFormDialog.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { + Box, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Grid, + MenuItem, + OutlinedInput, + Select, + SelectChangeEvent, + Switch, + TextField, +} from "@mui/material"; +import { Item, ItemRelation, Folder, Tag } from "@/app/lib/db/types"; +import { createItem } from "@/app/lib/items"; +import { createFolder } from "@/app/lib/folders"; +import theme from "@/app/lib/theme"; +import React from "react"; +import { createItemRelation } from "@/app/lib/itemRelation"; + +const SEP = "\t"; + +const ITEM_HEIGHT = 48; +const ITEM_PADDING_TOP = 8; +const MenuProps = { + PaperProps: { + style: { + maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, + width: 250, + }, + }, +}; + +interface Props { + data?: Item; + folders: Folder[]; + tags: Tag[]; + open: boolean; + onClose: () => void; +} + +export default function ItemFormDialog(props: Props) { + const { data, folders, tags, open, onClose } = props; + const [selectedTags, setSelectedTags] = React.useState([]); + + const handleClose = () => { + onClose(); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const formJson = Object.fromEntries((formData as any).entries()); + console.debug(formJson); + + const folderId = Number(formJson.folder); + const path = formJson.path; + const name = formJson.name || undefined; + const starred = formJson.starred === "on"; + const response = await createItem(path, folderId, name, starred); + + if (response !== null && selectedTags.length > 0) { + for (const tag of selectedTags) { + const tagId = Number(tag.split(SEP)[0]); + await createItemRelation(tagId, response.id); + } + } + + handleClose(); + }; + + const handleChange = (event: SelectChangeEvent) => { + const { + target: { value }, + } = event; + setSelectedTags( + // On autofill we get a stringified value. + typeof value === "string" ? value.split(",") : value + ); + }; + + return ( + + Item + + + + } + label="Star" + /> + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/components/tag/FormDialog.tsx b/app/database/formDialog/tagFormDialog.tsx similarity index 69% rename from app/components/tag/FormDialog.tsx rename to app/database/formDialog/tagFormDialog.tsx index e3ba286..0502287 100644 --- a/app/components/tag/FormDialog.tsx +++ b/app/database/formDialog/tagFormDialog.tsx @@ -15,15 +15,17 @@ import { TextField, } from "@mui/material"; import { createTag } from "@/app/lib/tags"; +import { createTagRelation } from "@/app/lib/tagRelation"; interface Props { - open: boolean; + existingTags: Tag[]; data?: Tag; + open: boolean; onClose: () => void; } -export default function FormDialog(props: Props) { - const { open, data, onClose } = props; +export default function TagFormDialog(props: Props) { + const { existingTags, data, open, onClose } = props; const handleClose = () => { onClose(); @@ -40,7 +42,12 @@ export default function FormDialog(props: Props) { const starred = formJson.starred === "on"; const textColor = formJson.textColor || undefined; const backColor = formJson.backColor || undefined; - await createTag(name, type, starred, textColor, backColor); + const response = await createTag(name, type, starred, textColor, backColor); + + const parentId = formJson.parent; + if (parentId && response) { + await createTagRelation(Number(parentId), response.id); + } handleClose(); }; @@ -58,7 +65,10 @@ export default function FormDialog(props: Props) { - } label="Star" /> + } + label="Star" + /> @@ -75,6 +86,7 @@ export default function FormDialog(props: Props) { labelId="type-label" label="Type" name="type" + defaultValue={TagType.Normal} required fullWidth > @@ -82,6 +94,21 @@ export default function FormDialog(props: Props) { Category + + +