settings page

This commit is contained in:
monoid 2024-04-06 23:19:53 +09:00
parent 3813dbfeff
commit 2df64ac2fe
20 changed files with 510 additions and 574 deletions

View File

@ -13,17 +13,20 @@
"dependencies": {
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"dbtype": "workspace:*",
"jotai": "^2.7.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-resizable-panels": "^2.0.16",
"swr": "^2.2.5",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"wouter": "^3.1.0"
},
"devDependencies": {

View File

@ -22,6 +22,7 @@ import NotFoundPage from "./page/404.tsx";
import LoginPage from "./page/loginPage.tsx";
import ProfilePage from "./page/profilesPage.tsx";
import ContentInfoPage from "./page/contentInfoPage.tsx";
import SettingPage from "./page/settingPage.tsx";
const App = () => {
return (
@ -33,10 +34,10 @@ const App = () => {
<Route path="/login" component={LoginPage} />
<Route path="/profile" component={ProfilePage}/>
<Route path="/doc/:id" component={ContentInfoPage}/>
<Route path="/setting" component={SettingPage} />
{/*
<Route path="/doc/:id/reader" component={<ReaderPage />}></Route>
<Route path="/difference" component={<DifferencePage />}></Route>
<Route path="/setting" component={<SettingPage />}></Route>
<Route path="/tags" component={<TagsPage />}></Route>*/}
<Route component={NotFoundPage} />
</Switch>

View File

@ -1,99 +0,0 @@
import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../../model/doc";
import { toQueryString } from "./util";
const baseurl = "/api/doc";
export * from "../../model/doc";
export class FetchFailError extends Error {}
export class ClientDocumentAccessor implements DocumentAccessor {
search: (search_word: string) => Promise<Document[]>;
addList: (content_list: DocumentBody[]) => Promise<number[]>;
async findByPath(basepath: string, filename?: string): Promise<Document[]> {
throw new Error("not allowed");
}
async findDeleted(content_type: string): Promise<Document[]> {
throw new Error("not allowed");
}
async findList(option?: QueryListOption | undefined): Promise<Document[]> {
let res = await fetch(`${baseurl}/search?${option !== undefined ? toQueryString(option) : ""}`);
if (res.status == 401) throw new FetchFailError("Unauthorized");
if (res.status !== 200) throw new FetchFailError("findList Failed");
let ret = await res.json();
return ret;
}
async findById(id: number, tagload?: boolean | undefined): Promise<Document | undefined> {
let res = await fetch(`${baseurl}/${id}`);
if (res.status !== 200) throw new FetchFailError("findById Failed");
let ret = await res.json();
return ret;
}
/**
* not implement
*/
async findListByBasePath(basepath: string): Promise<Document[]> {
throw new Error("not implement");
return [];
}
async update(c: Partial<Document> & { id: number }): Promise<boolean> {
const { id, ...rest } = c;
const res = await fetch(`${baseurl}/${id}`, {
method: "POST",
body: JSON.stringify(rest),
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
async add(c: DocumentBody): Promise<number> {
throw new Error("not allow");
const res = await fetch(`${baseurl}`, {
method: "POST",
body: JSON.stringify(c),
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
async del(id: number): Promise<boolean> {
const res = await fetch(`${baseurl}/${id}`, {
method: "DELETE",
});
const ret = await res.json();
return ret;
}
async addTag(c: Document, tag_name: string): Promise<boolean> {
const { id, ...rest } = c;
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
method: "POST",
body: JSON.stringify(rest),
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
async delTag(c: Document, tag_name: string): Promise<boolean> {
const { id, ...rest } = c;
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
method: "DELETE",
body: JSON.stringify(rest),
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
}
export const CDocumentAccessor = new ClientDocumentAccessor();
export const makeThumbnailUrl = (x: Document) => {
return `${baseurl}/${x.id}/${x.content_type}/thumbnail`;
};
export default CDocumentAccessor;

View File

@ -1,28 +0,0 @@
type Representable = string | number | boolean;
type ToQueryStringA = {
[name: string]: Representable | Representable[] | undefined;
};
export const toQueryString = (obj: ToQueryStringA) => {
return Object.entries(obj)
.filter((e): e is [string, Representable | Representable[]] => e[1] !== undefined)
.map((e) => (e[1] instanceof Array ? e[1].map((f) => `${e[0]}=${f}`).join("&") : `${e[0]}=${e[1]}`))
.join("&");
};
export const QueryStringToMap = (query: string) => {
const keyValue = query.slice(query.indexOf("?") + 1).split("&");
const param: { [k: string]: string | string[] } = {};
keyValue.forEach((p) => {
const [k, v] = p.split("=");
const pv = param[k];
if (pv === undefined) {
param[k] = v;
} else if (typeof pv === "string") {
param[k] = [pv, v];
} else {
pv.push(v);
}
});
return param;
};

View File

@ -1,9 +1,10 @@
import type { Document } from "dbtype/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import TagBadge from "@/components/gallery/TagBadge.tsx";
import { useEffect, useRef, useState } from "react";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { Link, useLocation } from "wouter";
import { LazyImage } from "./LazyImage.tsx";
import StyledLink from "./StyledLink.tsx";
function clipTagsWhenOverflow(tags: string[], limit: number) {
let l = 0;
@ -22,12 +23,11 @@ export function GalleryCard({
}: { doc: Document; }) {
const ref = useRef<HTMLDivElement>(null);
const [clipCharCount, setClipCharCount] = useState(200);
const [location] = useLocation();
const artists = x.tags.filter(x => x.startsWith("artist:")).map(x => x.replace("artist:", ""));
const groups = x.tags.filter(x => x.startsWith("group:")).map(x => x.replace("group:", ""));
const originalTags = x.tags.map(x => x.replace("artist:", "").replace("group:", ""));
const originalTags = x.tags.filter(x => !x.startsWith("artist:") && !x.startsWith("group:"));
const clippedTags = clipTagsWhenOverflow(originalTags, clipCharCount);
useEffect(() => {
@ -55,16 +55,27 @@ export function GalleryCard({
<div className="flex-1 flex flex-col">
<CardHeader className="flex-none">
<CardTitle>
<Link href={`/doc/${x.id}`} state={{fromUrl: location}}>{x.title}</Link>
<StyledLink className="" to={`/doc/${x.id}`}>
{x.title}
</StyledLink>
</CardTitle>
<CardDescription>
{artists.join(", ")} {groups.length > 0 && "|"} {groups.join(", ")}
{artists.map((x, i) => <Fragment key={`artist:${x}`}>
<StyledLink to={`/search?allow_tag=artist:${x}`}>{x}</StyledLink>
{i + 1 < artists.length && <span className="opacity-50">, </span>}
</Fragment>)}
{groups.length > 0 && <span key={"sep"}>{" | "}</span>}
{groups.map((x, i) => <Fragment key={`group:${x}`}>
<StyledLink to={`/search?allow_tag=group:${x}`}>{x}</StyledLink>
{i + 1 < groups.length && <span className="opacity-50">, </span>}
</Fragment>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1" ref={ref}>
<ul className="flex flex-wrap gap-2 items-baseline content-start">
{clippedTags.map(tag => <TagBadge key={tag} tagname={tag} className="" />)}
{clippedTags.length < originalTags.length && <TagBadge tagname="..." className="" disabled />}
{clippedTags.length < originalTags.length && <TagBadge key={"..."} tagname="..." className="" disabled />}
</ul>
</CardContent>
</div>

View File

@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
import { Link } from "wouter";
type StyledLinkProps = {
children?: React.ReactNode;
className?: string;
to: string;
};
export default function StyledLink({ children, className, ...rest }: StyledLinkProps) {
return <Link {...rest}
className={cn("hover:underline underline-offset-1 rounded-sm focus-visible:ring-1 focus-visible:ring-ring", className)}
>{children}</Link>
}

View File

@ -2,28 +2,35 @@ import { badgeVariants } from "@/components/ui/badge.tsx";
import { Link } from "wouter";
import { cn } from "@/lib/utils.ts";
const femaleTagPrefix = "female:";
const maleTagPrefix = "male:";
function getTagKind(tagname: string) {
if (tagname.startsWith(femaleTagPrefix)) {
return "female";
if (tagname.match(":") === null) {
return "default";
}
if (tagname.startsWith(maleTagPrefix)){
return "male";
}
return "default";
const prefix = tagname.split(":")[0];
return prefix;
}
function toPrettyTagname(tagname: string): string {
const kind = getTagKind(tagname);
const name = tagname.slice(kind.length + 1);
switch (kind) {
case "male":
return `${tagname.slice(maleTagPrefix.length)}`;
return `${name}`;
case "female":
return `${tagname.slice(femaleTagPrefix.length)}`;
default:
return `${name}`;
case "artist":
return `🎨 ${name}`;
case "group":
return `🖿 ${name}`;
case "series":
return `📚 ${name}`
case "character":
return `👤 ${name}`;
case "default":
return tagname;
default:
return name;
}
}
@ -35,6 +42,11 @@ export default function TagBadge(props: { tagname: string, className?: string; d
"px-1",
kind === "male" && "bg-[#2a7bbf] hover:bg-[#3091e7]",
kind === "female" && "bg-[#c21f58] hover:bg-[#db2d67]",
kind === "artist" && "bg-[#319795] hover:bg-[#38a89d]",
kind === "group" && "bg-[#805ad5] hover:bg-[#8b5cd6]",
kind === "series" && "bg-[#dc8f09] hover:bg-[#e69d17]",
kind === "character" && "bg-[#52952c] hover:bg-[#6cc24a]",
kind === "type" && "bg-[#d53f8c] hover:bg-[#e24996]",
kind === "default" && "bg-[#4a5568] hover:bg-[#718096]",
props.disabled && "opacity-50",
props.className,

View File

@ -0,0 +1,42 @@
import * as React from "react"
import { CheckIcon } from "@radix-ui/react-icons"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<CheckIcon className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,4 @@
export async function fetcher(url: string) {
const res = await fetch(url);
return res.json();
}

View File

@ -0,0 +1,7 @@
import useSWR from "swr";
import type { Document } from "dbtype/api";
import { fetcher } from "./fetcher";
export function useGalleryDoc(id: string) {
return useSWR<Document>(`/api/doc/${id}`, fetcher);
}

View File

@ -0,0 +1,23 @@
import useSWR from "swr";
import type { Document } from "dbtype/api";
import { fetcher } from "./fetcher";
interface SearchParams {
word?: string;
tags?: string;
limit?: number;
cursor?: number;
}
export function useSearchGallery({
word, tags, limit, cursor,
}: SearchParams) {
const search = new URLSearchParams();
if (word) search.set("word", word);
if (tags) search.set("allow_tag", tags);
if (limit) search.set("limit", limit.toString());
if (cursor) search.set("cursor", cursor.toString());
return useSWR<
Document[]
>(`/api/doc/search?${search.toString()}`, fetcher);
}

View File

@ -1,20 +1,133 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useGalleryDoc } from "../hook/useGalleryDoc";
import TagBadge from "@/components/gallery/TagBadge";
import StyledLink from "@/components/gallery/StyledLink";
import { cn } from "@/lib/utils";
export interface ContentInfoPageProps {
params: {
id: string;
};
}
interface TagClassifyResult {
artist: string[];
group: string[];
series: string[];
type: string[];
character: string[];
rest: string[];
}
function classifyTags(tags: string[]): TagClassifyResult {
const result = {
artist: [],
group: [],
series: [],
type: [],
character: [],
rest: [],
} as TagClassifyResult;
const tagKind = new Set(["artist", "group", "series", "type", "character"]);
for (const tag of tags) {
const split = tag.split(":");
if (split.length !== 2) {
continue;
}
const [prefix, name] = split;
if (tagKind.has(prefix)) {
result[prefix as keyof TagClassifyResult].push(name);
} else {
result.rest.push(tag);
}
}
return result;
}
export function ContentInfoPage({params}: ContentInfoPageProps) {
export function ContentInfoPage({ params }: ContentInfoPageProps) {
const { data, error, isLoading } = useGalleryDoc(params.id);
if (isLoading) {
return <div className="p-4">Loading...</div>
}
if (error) {
return <div className="p-4">Error: {String(error)}</div>
}
if (!data) {
return <div className="p-4">Not found</div>
}
const tags = data?.tags ?? [];
const classifiedTags = classifyTags(tags);
return (
<div className="p-4">
<h1>ContentInfoPage</h1>
{params.id}
<p>Find me in packages/client/src/page/contentInfoPage.tsx</p>
<div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733]
rounded-xl shadow-lg overflow-hidden">
<img
className="max-w-full max-h-full object-cover object-center"
src={`/api/doc/${data.id}/comic/thumbnail`}
alt={data.title} />
</div>
<Card className="flex-1">
<CardHeader>
<CardTitle>{data.title}</CardTitle>
<CardDescription>
<StyledLink to={`/search?allow_tag=type:${classifiedTags.type[0] ?? ""}`} className="text-sm">
{classifiedTags.type[0] ?? "N/A"}
</StyledLink>
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-y-4 gap-x-3 lg:grid-cols-2">
<DescTagItem name="artist" items={classifiedTags.artist} />
<DescTagItem name="group" items={classifiedTags.group} />
<DescTagItem name="series" items={classifiedTags.series} />
<DescTagItem name="character" items={classifiedTags.character} />
<DescItem name="Created At">{new Date(data.created_at).toLocaleString()}</DescItem>
<DescItem name="Modified At">{new Date(data.modified_at).toLocaleString()}</DescItem>
<DescItem name="Path">{`${data.basepath}/${data.filename}`}</DescItem>
<DescItem name="Page Count">{JSON.stringify(data.additional)}</DescItem>
</div>
<div className="grid mt-4">
<span className="text-muted-foreground text-sm">Tags</span>
<ul className="mt-2 flex flex-wrap gap-1">
{classifiedTags.rest.map((tag) => <TagBadge key={tag} tagname={tag} />)}
</ul>
</div>
</CardContent>
</Card>
</div>
);
}
export default ContentInfoPage;
function DescItem({ name, children, className }: {
name: string,
className?: string,
children?: React.ReactNode
}) {
return <div className={cn("grid content-start", className)}>
<span className="text-muted-foreground text-sm">{name}</span>
<span className="text-primary leading-4 font-medium">{children}</span>
</div>;
}
function DescTagItem({
items,
name,
className,
}: {
name: string;
items: string[];
className?: string;
}) {
return <DescItem name={name} className={className}>
{items.length === 0 ? "N/A" : items.map(
(x) => <StyledLink key={x} to={`/search?allow_tag=${name}:${x}`}>{x}</StyledLink>
)}
</DescItem>
}

View File

@ -1,136 +0,0 @@
import { IconButton, Theme, Typography } from "@mui/material";
import FullscreenIcon from "@mui/icons-material/Fullscreen";
import React, { useEffect, useRef, useState } from "react";
import { Route, Routes, useLocation, useParams } from "react-router-dom";
import DocumentAccessor, { Document } from "../accessor/document";
import { LoadingCircle } from "../component/loading";
import { CommonMenuList, ContentInfo, Headline } from "../component/mod";
import { NotFoundPage } from "./404";
import { getPresenter } from "./reader/reader";
import { PagePad } from "../component/pagepad";
export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
export const makeComicReaderUrl = (id: number) => `/doc/${id}/reader`;
type DocumentState = {
doc: Document | undefined;
notfound: boolean;
};
export function ReaderPage(props?: {}) {
const location = useLocation();
const match = useParams<{ id: string }>();
if (match == null) {
throw new Error("unreachable");
}
const id = Number.parseInt(match.id ?? "NaN");
const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false });
const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>;
const fullScreenTargetRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
(async () => {
if (!isNaN(id)) {
const c = await DocumentAccessor.findById(id);
setInfo({ doc: c, notfound: c === undefined });
}
})();
}, []);
if (isNaN(id)) {
return (
<Headline menu={menu_list()}>
<Typography variant="h2">Oops. Invalid ID</Typography>
</Headline>
);
} else if (info.notfound) {
return (
<Headline menu={menu_list()}>
<Typography variant="h2">Content has been removed.</Typography>
</Headline>
);
} else if (info.doc === undefined) {
return (
<Headline menu={menu_list()}>
<LoadingCircle />
</Headline>
);
} else {
const ReaderPage = getPresenter(info.doc);
return (
<Headline
menu={menu_list(location.pathname)}
rightAppbar={
<IconButton
edge="start"
aria-label="account of current user"
aria-haspopup="true"
onClick={() => {
if (fullScreenTargetRef.current != null && document.fullscreenEnabled) {
fullScreenTargetRef.current.requestFullscreen();
}
}}
color="inherit"
>
<FullscreenIcon />
</IconButton>
}
>
<ReaderPage doc={info.doc} fullScreenTarget={fullScreenTargetRef}></ReaderPage>
</Headline>
);
}
}
export const DocumentAbout = (prop?: {}) => {
const match = useParams<{ id: string }>();
if (match == null) {
throw new Error("unreachable");
}
const id = Number.parseInt(match.id ?? "NaN");
const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false });
const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>;
useEffect(() => {
(async () => {
if (!isNaN(id)) {
const c = await DocumentAccessor.findById(id);
setInfo({ doc: c, notfound: c === undefined });
}
})();
}, []);
if (isNaN(id)) {
return (
<Headline menu={menu_list()}>
<PagePad>
<Typography variant="h2">Oops. Invalid ID</Typography>
</PagePad>
</Headline>
);
} else if (info.notfound) {
return (
<Headline menu={menu_list()}>
<PagePad>
<Typography variant="h2">Content has been removed.</Typography>
</PagePad>
</Headline>
);
} else if (info.doc === undefined) {
return (
<Headline menu={menu_list()}>
<PagePad>
<LoadingCircle />
</PagePad>
</Headline>
);
} else {
return (
<Headline menu={menu_list()}>
<PagePad>
<ContentInfo document={info.doc}></ContentInfo>
</PagePad>
</Headline>
);
}
};

View File

@ -1,38 +1,9 @@
import useSWR from "swr";
import { useSearch } from "wouter";
import type { Document } from "dbtype/api";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { GalleryCard } from "@/components/gallery/GalleryCard.tsx";
import TagBadge from "@/components/gallery/TagBadge.tsx";
async function fetcher(url: string) {
const res = await fetch(url);
return res.json();
}
interface SearchParams {
word?: string;
tags?: string;
limit?: number;
cursor?: number;
}
function useSearchGallery({
word,
tags,
limit,
cursor,
}: SearchParams) {
const search = new URLSearchParams();
if (word) search.set("word", word);
if (tags) search.set("allow_tag", tags);
if (limit) search.set("limit", limit.toString());
if (cursor) search.set("cursor", cursor.toString());
return useSWR<
Document[]
>(`/api/doc/search?${search.toString()}`, fetcher);
}
import { useSearchGallery } from "../hook/useSearchGallery";
export default function Gallery() {
const search = useSearch();

View File

@ -1,90 +0,0 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
MenuList,
Paper,
TextField,
Typography,
useTheme,
} from "@mui/material";
import React, { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { CommonMenuList, Headline } from "../component/mod";
import { UserContext } from "../state";
import { doLogin as doSessionLogin } from "../state";
import { PagePad } from "../component/pagepad";
export const LoginPage = () => {
const theme = useTheme();
const [userLoginInfo, setUserLoginInfo] = useState({ username: "", password: "" });
const [openDialog, setOpenDialog] = useState({ open: false, message: "" });
const { setUsername, setPermission } = useContext(UserContext);
const navigate = useNavigate();
const handleDialogClose = () => {
setOpenDialog({ ...openDialog, open: false });
};
const doLogin = async () => {
try {
const b = await doSessionLogin(userLoginInfo);
if (typeof b === "string") {
setOpenDialog({ open: true, message: b });
return;
}
console.log(`login as ${b.username}`);
setUsername(b.username);
setPermission(b.permission);
} catch (e) {
if (e instanceof Error) {
console.error(e);
setOpenDialog({ open: true, message: e.message });
} else console.error(e);
return;
}
navigate("/");
};
const menu = CommonMenuList();
return (
<Headline menu={menu}>
<PagePad>
<Paper style={{ width: theme.spacing(40), padding: theme.spacing(4), alignSelf: "center" }}>
<Typography variant="h4">Login</Typography>
<div style={{ minHeight: theme.spacing(2) }}></div>
<form style={{ display: "flex", flexFlow: "column", alignItems: "stretch" }}>
<TextField
label="username"
onChange={(e) => setUserLoginInfo({ ...userLoginInfo, username: e.target.value ?? "" })}
></TextField>
<TextField
label="password"
type="password"
onKeyDown={(e) => {
if (e.key === "Enter") doLogin();
}}
onChange={(e) => setUserLoginInfo({ ...userLoginInfo, password: e.target.value ?? "" })}
/>
<div style={{ minHeight: theme.spacing(2) }}></div>
<div style={{ display: "flex" }}>
<Button onClick={doLogin}>login</Button>
<Button>signin</Button>
</div>
</form>
</Paper>
<Dialog open={openDialog.open} onClose={handleDialogClose}>
<DialogTitle>Login Failed</DialogTitle>
<DialogContent>
<DialogContentText>detail : {openDialog.message}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose} color="primary" autoFocus>
Close
</Button>
</DialogActions>
</Dialog>
</PagePad>
</Headline>
);
};

View File

@ -1,149 +0,0 @@
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Grid,
Paper,
TextField,
Theme,
Typography,
} from "@mui/material";
import React, { useContext, useState } from "react";
import { CommonMenuList, Headline } from "../component/mod";
import { UserContext } from "../state";
import { PagePad } from "../component/pagepad";
const useStyles = (theme: Theme) => ({
paper: {
alignSelf: "center",
padding: theme.spacing(2),
},
formfield: {
display: "flex",
flexFlow: "column",
},
});
export function ProfilePage() {
const userctx = useContext(UserContext);
// const classes = useStyles();
const menu = CommonMenuList();
const [pw_open, set_pw_open] = useState(false);
const [oldpw, setOldpw] = useState("");
const [newpw, setNewpw] = useState("");
const [newpwch, setNewpwch] = useState("");
const [msg_dialog, set_msg_dialog] = useState({ opened: false, msg: "" });
const permission_list = userctx.permission.map((p) => <Chip key={p} label={p}></Chip>);
const isElectronContent = ((window["electron"] as any) !== undefined) as boolean;
const handle_open = () => set_pw_open(true);
const handle_close = () => {
set_pw_open(false);
setNewpw("");
setNewpwch("");
};
const handle_ok = async () => {
if (newpw != newpwch) {
set_msg_dialog({ opened: true, msg: "password and password check is not equal." });
handle_close();
return;
}
if (isElectronContent) {
const elec = window["electron"] as any;
const success = elec.passwordReset(userctx.username, newpw);
if (!success) {
set_msg_dialog({ opened: true, msg: "user not exist." });
}
} else {
const res = await fetch("/user/reset", {
method: "POST",
body: JSON.stringify({
username: userctx.username,
oldpassword: oldpw,
newpassword: newpw,
}),
headers: {
"content-type": "application/json",
},
});
if (res.status != 200) {
set_msg_dialog({ opened: true, msg: "failed to change password." });
}
}
handle_close();
};
return (
<Headline menu={menu}>
<PagePad>
<Paper /*className={classes.paper}*/>
<Grid container direction="column" alignItems="center">
<Grid item>
<Typography variant="h4">{userctx.username}</Typography>
</Grid>
<Divider></Divider>
<Grid item>Permission</Grid>
<Grid item>{permission_list.length == 0 ? "-" : permission_list}</Grid>
<Grid item>
<Button onClick={handle_open}>Password Reset</Button>
</Grid>
</Grid>
</Paper>
<Dialog open={pw_open} onClose={handle_close}>
<DialogTitle>Password Reset</DialogTitle>
<DialogContent>
<Typography>type the old and new password</Typography>
<div /*className={classes.formfield}*/>
{!isElectronContent && (
<TextField
autoFocus
margin="dense"
type="password"
label="old password"
value={oldpw}
onChange={(e) => setOldpw(e.target.value)}
></TextField>
)}
<TextField
margin="dense"
type="password"
label="new password"
value={newpw}
onChange={(e) => setNewpw(e.target.value)}
></TextField>
<TextField
margin="dense"
type="password"
label="new password check"
value={newpwch}
onChange={(e) => setNewpwch(e.target.value)}
></TextField>
</div>
</DialogContent>
<DialogActions>
<Button onClick={handle_close} color="primary">
Cancel
</Button>
<Button onClick={handle_ok} color="primary">
Ok
</Button>
</DialogActions>
</Dialog>
<Dialog open={msg_dialog.opened} onClose={() => set_msg_dialog({ opened: false, msg: "" })}>
<DialogTitle>Alert!</DialogTitle>
<DialogContent>
<DialogContentText>{msg_dialog.msg}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => set_msg_dialog({ opened: false, msg: "" })} color="primary">
Close
</Button>
</DialogActions>
</Dialog>
</PagePad>
</Headline>
);
}

View File

@ -8,6 +8,8 @@ export function ProfilePage() {
console.error("User session expired. Redirecting to login page.");
return <Redirect to="/login" />;
}
// TODO: Add a logout button
// TODO: Add a change password button
return (
<div className="p-4">
<Card>

View File

@ -1,17 +0,0 @@
import { Paper, Typography } from "@mui/material";
import React from "react";
import { CommonMenuList, Headline } from "../component/mod";
import { PagePad } from "../component/pagepad";
export const SettingPage = () => {
const menu = CommonMenuList();
return (
<Headline menu={menu}>
<PagePad>
<Paper>
<Typography variant="h2">Setting</Typography>
</Paper>
</PagePad>
</Headline>
);
};

View File

@ -0,0 +1,102 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useEffect } from "react";
import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
function LightModeView() {
return <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
</div>
</div>;
}
function DarkModeView() {
return <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
</div>
</div>
}
export function SettingPage() {
const { setTernaryDarkMode, ternaryDarkMode, isDarkMode } = useTernaryDarkMode();
const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
useEffect(() => {
if (isDarkMode) {
document.body.classList.add("dark");
}
else {
document.body.classList.remove("dark");
}
}, [isDarkMode]);
return (
<div className="p-4">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div>
<h3 className="text-lg">Appearance</h3>
<span className="text-muted-foreground text-sm">Dark mode</span>
</div>
<RadioGroup value={ternaryDarkMode} onValueChange={(v) => setTernaryDarkMode(v as TernaryDarkMode)}
className="flex space-x-2 items-center"
>
<RadioGroupItem id="dark" value="dark" className="sr-only" />
<Label htmlFor="dark">
<div className="grid place-items-center">
<DarkModeView />
<span>Dark Mode</span>
</div>
</Label>
<RadioGroupItem id="light" value="light" className="sr-only" />
<Label htmlFor="light">
<div className="grid place-items-center">
<LightModeView />
<span>Light Mode</span>
</div>
</Label>
<RadioGroupItem id="system" value="system" className="sr-only" />
<Label htmlFor="system">
<div className="grid place-items-center">
{isSystemDarkMode ? <DarkModeView /> : <LightModeView />}
<span>System Mode</span>
</div>
</Label>
</RadioGroup>
</div>
</CardContent>
</Card>
</div>
)
}
export default SettingPage;

View File

@ -20,6 +20,9 @@ importers:
'@radix-ui/react-label':
specifier: ^2.0.2
version: 2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-radio-group':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot':
specifier: ^1.0.2
version: 1.0.2(@types/react@18.2.71)(react@18.2.0)
@ -35,6 +38,9 @@ importers:
dbtype:
specifier: workspace:*
version: link:../dbtype
jotai:
specifier: ^2.7.2
version: 2.7.2(@types/react@18.2.71)(react@18.2.0)
react:
specifier: ^18.2.0
version: 18.2.0
@ -53,6 +59,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.3)
usehooks-ts:
specifier: ^3.1.0
version: 3.1.0(react@18.2.0)
wouter:
specifier: ^3.1.0
version: 3.1.0(react@18.2.0)
@ -987,6 +996,30 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.71)(react@18.2.0)
'@types/react': 18.2.71
'@types/react-dom': 18.2.22
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.71)(react@18.2.0):
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
peerDependencies:
@ -1015,6 +1048,20 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-direction@1.0.1(@types/react@18.2.71)(react@18.2.0):
resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.24.1
'@types/react': 18.2.71
react: 18.2.0
dev: false
/@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
peerDependencies:
@ -1178,6 +1225,65 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.1
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@types/react': 18.2.71
'@types/react-dom': 18.2.22
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.1
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.71)(react@18.2.0)
'@types/react': 18.2.71
'@types/react-dom': 18.2.22
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-slot@1.0.2(@types/react@18.2.71)(react@18.2.0):
resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
peerDependencies:
@ -1283,6 +1389,20 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-previous@1.0.1(@types/react@18.2.71)(react@18.2.0):
resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@babel/runtime': 7.24.1
'@types/react': 18.2.71
react: 18.2.0
dev: false
/@radix-ui/react-use-rect@1.0.1(@types/react@18.2.71)(react@18.2.0):
resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
peerDependencies:
@ -3443,6 +3563,22 @@ packages:
resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==}
hasBin: true
/jotai@2.7.2(@types/react@18.2.71)(react@18.2.0):
resolution: {integrity: sha512-6Ft5kpNu8p93Ssf1Faoza3hYQZRIYp7rioK8MwTTFnbQKwUyZElwquPwl1h6U0uo9hC0jr+ghO3gcSjc6P35/Q==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=17.0.0'
react: '>=17.0.0'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
dependencies:
'@types/react': 18.2.71
react: 18.2.0
dev: false
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -3678,6 +3814,10 @@ packages:
resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==}
dev: true
/lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false
/lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
dev: false
@ -5069,6 +5209,16 @@ packages:
react: 18.2.0
dev: false
/usehooks-ts@3.1.0(react@18.2.0):
resolution: {integrity: sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==}
engines: {node: '>=16.15.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18
dependencies:
lodash.debounce: 4.0.8
react: 18.2.0
dev: false
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}