(feat): add profile page
(feat): show user online-status (feat): make markdown input more modular
This commit is contained in:
parent
168694ae86
commit
55da8026a7
12 changed files with 609 additions and 63 deletions
|
|
@ -1,20 +1,58 @@
|
|||
import type { User } from "@tensamin/user/context";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@tensamin/ui";
|
||||
import { reduceDisplay } from "./utils";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@tensamin/ui";
|
||||
import { Card, CardHeader } from "@tensamin/ui";
|
||||
import { Skeleton } from "@tensamin/ui";
|
||||
import { getStatusColor } from "@tensamin/shared/data";
|
||||
|
||||
export function Basic(props: { user: User }) {
|
||||
export function Basic({
|
||||
user,
|
||||
extra,
|
||||
}: {
|
||||
user: User;
|
||||
extra?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="animate-in fade-in duration-300 rounded-2xl py-0 m-px">
|
||||
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={props.user.avatar} />
|
||||
<AvatarFallback>{reduceDisplay(props.user.display)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||
<p>{props.user.display}</p>
|
||||
<div className="relative shrink-0 overflow-visible">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>
|
||||
{user.display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="absolute -bottom-0.75 -right-0.75 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-card">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: getStatusColor(user.online_status),
|
||||
}}
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{user.online_status
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||
<p>{user.display}</p>
|
||||
</div>
|
||||
<div className="pr-2">{extra}</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Executes reduceDisplay.
|
||||
* @param display Parameter display.
|
||||
* @returns unknown.
|
||||
*/
|
||||
export function reduceDisplay(display: string) {
|
||||
const words = display.split(" ");
|
||||
if (words.length === 1) {
|
||||
return display.slice(0, 2).toUpperCase();
|
||||
} else {
|
||||
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,26 @@ import {
|
|||
SidebarContent,
|
||||
SidebarFooter,
|
||||
useSidebar,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
DialogClose,
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@tensamin/ui";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useIsMobile } from "@tensamin/ui";
|
||||
|
|
@ -14,18 +34,168 @@ import { MobileNavbar } from "./navbar";
|
|||
|
||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||
import { useShowMobileNavbar } from "@/routes/app/layout";
|
||||
/**
|
||||
* Renders the conversation sidebar with account summary and conversation list.
|
||||
* On mobile the sidebar is always kept in the DOM and hidden via CSS
|
||||
* (opacity + translateX) instead of being unmounted. This keeps the DOM and
|
||||
* React state alive while the drawer is closed, allowing the sidebar to open
|
||||
* instantly on subsequent toggles.
|
||||
* @returns Sidebar JSX.
|
||||
*/
|
||||
import { Ellipsis, Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
import type z from "zod";
|
||||
import { ttp } from "@tensamin/shared/data";
|
||||
import { useTTP } from "@tensamin/ttp";
|
||||
|
||||
type OnlineStatus = z.infer<
|
||||
typeof ttp.get_user_data.response.shape.online_status
|
||||
>;
|
||||
|
||||
const onlineStatusLabels: Record<OnlineStatus, string> = {
|
||||
user_online: "Online",
|
||||
user_offline: "Offline",
|
||||
user_dnd: "Do not disturb",
|
||||
user_idle: "Idle",
|
||||
user_wc: "Away",
|
||||
user_borked: "Borked",
|
||||
iota_offline: "Iota offline",
|
||||
iota_online: "Iota online",
|
||||
iota_borked: "Iota borked",
|
||||
};
|
||||
|
||||
function StatusDialog({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
send,
|
||||
draftStatus,
|
||||
setDraftStatus,
|
||||
draftOnlineStatus,
|
||||
setDraftOnlineStatus,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
saveSucceeded,
|
||||
setSaveSucceeded,
|
||||
}: {
|
||||
user: User;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
send: ReturnType<typeof useTTP>["send"];
|
||||
draftStatus: string;
|
||||
setDraftStatus: (value: string) => void;
|
||||
draftOnlineStatus: OnlineStatus;
|
||||
setDraftOnlineStatus: (value: OnlineStatus) => void;
|
||||
errorMessage: string;
|
||||
setErrorMessage: (value: string) => void;
|
||||
saveSucceeded: boolean;
|
||||
setSaveSucceeded: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDraftStatus(user.status ?? "");
|
||||
setDraftOnlineStatus(user.online_status);
|
||||
setErrorMessage("");
|
||||
setSaveSucceeded(false);
|
||||
}
|
||||
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Status</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="user-status">Status message</Label>
|
||||
<Input
|
||||
id="user-status"
|
||||
placeholder="Getting snacks..."
|
||||
value={draftStatus}
|
||||
onChange={(e) => {
|
||||
setSaveSucceeded(false);
|
||||
setErrorMessage("");
|
||||
setDraftStatus(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="user-online-status">Online status</Label>
|
||||
<Select
|
||||
value={draftOnlineStatus}
|
||||
onValueChange={(value) => {
|
||||
setSaveSucceeded(false);
|
||||
setErrorMessage("");
|
||||
setDraftOnlineStatus(value ?? "user_online");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full" id="user-online-status">
|
||||
<SelectValue placeholder="Online">
|
||||
{onlineStatusLabels[draftOnlineStatus]}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="p-1">
|
||||
<SelectItem value="user_online">Online</SelectItem>
|
||||
<SelectItem value="user_offline">Offline</SelectItem>
|
||||
<SelectItem value="user_idle">Idle</SelectItem>
|
||||
<SelectItem value="user_dnd">Do not disturb</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="destructive">Cancel</Button>} />
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const payload = {
|
||||
...(draftStatus && { status: draftStatus }),
|
||||
online_status: draftOnlineStatus,
|
||||
};
|
||||
|
||||
const validation =
|
||||
ttp.change_user_data.request.safeParse(payload);
|
||||
|
||||
if (!validation.success) {
|
||||
setSaveSucceeded(false);
|
||||
setErrorMessage(
|
||||
validation.error.issues[0]?.message ?? "Invalid status data",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await send("change_user_data", validation.data);
|
||||
setSaveSucceeded(true);
|
||||
setErrorMessage("");
|
||||
} catch (err) {
|
||||
setSaveSucceeded(false);
|
||||
setErrorMessage("Failed to update status: " + err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{saveSucceeded ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check className="size-4" />
|
||||
Saved
|
||||
</span>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const isMobile = useIsMobile();
|
||||
const showMobileNavbar = useShowMobileNavbar();
|
||||
const { openMobile, setOpenMobile } = useSidebar();
|
||||
const [draftStatus, setDraftStatus] = useState("");
|
||||
const [draftOnlineStatus, setDraftOnlineStatus] =
|
||||
useState<OnlineStatus>("user_online");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [statusErrorMessage, setStatusErrorMessage] = useState("");
|
||||
const [statusSaveSucceeded, setStatusSaveSucceeded] = useState(false);
|
||||
|
||||
const { send } = useTTP();
|
||||
|
||||
const content = (
|
||||
<>
|
||||
|
|
@ -39,7 +209,67 @@ export default function Sidebar() {
|
|||
<Wrapper
|
||||
loading={<Loading />}
|
||||
userId={"own"}
|
||||
component={(user) => <Basic user={user} />}
|
||||
component={(user) => (
|
||||
<>
|
||||
<Basic
|
||||
user={user}
|
||||
extra={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open profile menu"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Ellipsis />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Profile</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setDraftStatus(user.status ?? "");
|
||||
setDraftOnlineStatus(user.online_status);
|
||||
setStatusErrorMessage("");
|
||||
setStatusSaveSucceeded(false);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Set Status
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
<StatusDialog
|
||||
user={user}
|
||||
open={dialogOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setDraftStatus(user.status ?? "");
|
||||
setDraftOnlineStatus(user.online_status);
|
||||
setStatusErrorMessage("");
|
||||
setStatusSaveSucceeded(false);
|
||||
}
|
||||
|
||||
setDialogOpen(nextOpen);
|
||||
}}
|
||||
send={send}
|
||||
draftStatus={draftStatus}
|
||||
setDraftStatus={setDraftStatus}
|
||||
draftOnlineStatus={draftOnlineStatus}
|
||||
setDraftOnlineStatus={setDraftOnlineStatus}
|
||||
errorMessage={statusErrorMessage}
|
||||
setErrorMessage={setStatusErrorMessage}
|
||||
saveSucceeded={statusSaveSucceeded}
|
||||
setSaveSucceeded={setStatusSaveSucceeded}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full">
|
||||
|
|
|
|||
Loading…
Reference in a new issue