add dashboard

This commit is contained in:
sk1982 2024-04-05 02:01:54 -04:00
parent f4c9804ded
commit 3f486b81fd
17 changed files with 725 additions and 5 deletions

View File

@ -0,0 +1,53 @@
'use strict';
var dbm;
var type;
var seed;
var fs = require('fs');
var path = require('path');
var Promise;
/**
* We receive the dbmigrate dependency from dbmigrate initially.
* This enables us to not have to rely on NODE_PATH.
*/
exports.setup = function(options, seedLink) {
dbm = options.dbmigrate;
type = dbm.dataType;
seed = seedLink;
Promise = options.Promise;
};
exports.up = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240402044205-add-dashboard-to-user-ext-up.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports.down = function(db) {
var filePath = path.join(__dirname, 'sqls', '20240402044205-add-dashboard-to-user-ext-down.sql');
return new Promise( function( resolve, reject ) {
fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
if (err) return reject(err);
console.log('received data: ' + data);
resolve(data);
});
})
.then(function(data) {
return db.runSql(data);
});
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,2 @@
ALTER TABLE actaeon_user_ext
DROP COLUMN dashboard;

View File

@ -0,0 +1,2 @@
ALTER TABLE actaeon_user_ext
ADD COLUMN dashboard MEDIUMTEXT;

82
package-lock.json generated
View File

@ -27,7 +27,9 @@
"react": "^18",
"react-day-picker": "^8.10.0",
"react-dom": "^18",
"react-grid-layout": "^1.4.4",
"react-icons": "^5.0.1",
"react-resizable": "^3.0.5",
"react-swipeable": "^7.0.1",
"react-virtualized": "^9.22.5",
"sass": "^1.72.0",
@ -42,6 +44,8 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-grid-layout": "^1.3.5",
"@types/react-resizable": "^3.0.7",
"@types/react-virtualized": "^9.21.29",
"autoprefixer": "^10.4.19",
"eslint": "^8",
@ -3433,6 +3437,24 @@
"@types/react": "*"
}
},
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-resizable": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-3.0.7.tgz",
"integrity": "sha512-V4N7/xDUME+cxKya/A73MmFrHofTupVdE45boRxeA8HL4Q5pJh3AuG0FWCEy2GB84unIMSRISyEAS/GHWum9EQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized": {
"version": "9.21.29",
"resolved": "https://registry.npmjs.org/@types/react-virtualized/-/react-virtualized-9.21.29.tgz",
@ -6247,6 +6269,11 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg=="
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@ -9584,6 +9611,44 @@
"react": "^18.2.0"
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/react-grid-layout": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz",
"integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==",
"dependencies": {
"clsx": "^2.0.0",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.5",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-icons": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz",
@ -9623,6 +9688,18 @@
}
}
},
"node_modules/react-resizable": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.0.3"
},
"peerDependencies": {
"react": ">= 16.3"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
@ -9844,6 +9921,11 @@
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",

View File

@ -33,7 +33,9 @@
"react": "^18",
"react-day-picker": "^8.10.0",
"react-dom": "^18",
"react-grid-layout": "^1.4.4",
"react-icons": "^5.0.1",
"react-resizable": "^3.0.5",
"react-swipeable": "^7.0.1",
"react-virtualized": "^9.22.5",
"sass": "^1.72.0",
@ -48,6 +50,8 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-grid-layout": "^1.3.5",
"@types/react-resizable": "^3.0.7",
"@types/react-virtualized": "^9.21.29",
"autoprefixer": "^10.4.19",
"eslint": "^8",

16
src/actions/dashboard.ts Normal file
View File

@ -0,0 +1,16 @@
'use server';
import { db } from '@/db';
import { requireUser } from './auth';
import { revalidatePath } from 'next/cache';
export const setDashboard = async (dashboard: string) => {
const user = await requireUser();
await db.updateTable('actaeon_user_ext')
.where('userId', '=', user.id)
.set({ dashboard })
.executeTakeFirst();
revalidatePath('/', 'page');
};

View File

@ -0,0 +1,433 @@
'use client';
import 'react-resizable/css/styles.css';
import 'react-grid-layout/css/styles.css';
import resolveConfig from 'tailwindcss/resolveConfig';
import tailwindConfig from '@/../tailwind.base';
import GridLayout, { ItemCallback, Layout, calculateUtils } from 'react-grid-layout';
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChuniNameplate } from '@/components/chuni/nameplate';
import { useBreakpoint } from '@/helpers/use-breakpoint';
import { useWindowListener } from '@/helpers/use-window-listener';
import { UserPayload } from '@/types/user';
import { ChuniUserData } from '@/actions/chuni/profile';
import { ChuniNoProfile } from '@/components/chuni/no-profile';
import { useUser } from '@/helpers/use-user';
import { Button, Card, CardBody, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Tooltip } from '@nextui-org/react';
import { ChuniAvatar } from '@/components/chuni/avatar';
import { Entries, IsEqual } from 'type-fest';
import { ChevronRightIcon, ChevronLeftIcon, DevicePhoneMobileIcon, PencilIcon, SquaresPlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { SaveIcon } from '@/components/save-icon';
import { Resizable, ResizableBox, ResizeCallbackData } from 'react-resizable';
import { ServerStatus } from '@/data/status';
import { ActaeonStatus } from '@/components/actaeon-status';
import { setDashboard } from '@/actions/dashboard';
const config = resolveConfig(tailwindConfig);
const COLS = {
xs: 6,
sm: 7,
md: 7,
lg: 9,
xl: 9,
'2xl': 11,
'3xl': 11,
'4xl': 11,
'5xl': 13,
'6xl': 13
} as { [k: string]: number; };
const OVERRIDE_COLS = new Map([
[COLS.xs, {
width: 412,
name: 'Mobile',
breakpoint: 0
}],
[COLS.sm, {
width: 768,
name: 'Small',
breakpoint: parseInt(config.theme.screens.sm)
}],
[COLS.lg, {
width: 1280,
name: 'Medium',
breakpoint: parseInt(config.theme.screens.lg)
}],
[COLS['2xl'], {
width: 1920,
name: 'Large',
breakpoint: parseInt(config.theme.screens['2xl'])
}],
[COLS['5xl'], {
width: 2160,
name: 'Extra Large',
breakpoint: parseInt(config.theme.screens['5xl'])
}]
]);
type Item<D> = {
aspect?: [number, number],
render: IsEqual<D, undefined> extends true ? (() => ReactNode) : ((data: D) => ReactNode),
available?: (user: UserPayload | null | undefined) => boolean,
minH?: number,
name: string
};
type ItemData = {
'chuni-nameplate': ChuniUserData,
'chuni-avatar': ChuniUserData,
'actaeon-status': ServerStatus
};
const NO_CHUNI_PROFILE = <Card className="w-full h-full"><CardBody><ChuniNoProfile /></CardBody></Card>;
const ITEMS: { [K in keyof ItemData]: Item<ItemData[K]> } = {
'chuni-nameplate': {
aspect: [576, 228],
render: d => d ? (<ChuniNameplate profile={d} className="h-full" />) : NO_CHUNI_PROFILE,
available: u => !!u?.chuni,
name: 'Chunithm Nameplate'
},
'chuni-avatar': {
aspect: [544, 588],
render: d => d ? (<ChuniAvatar className="h-full"
wear={d.avatarWearTexture}
head={d.avatarHeadTexture}
face={d.avatarFaceTexture}
skin={d.avatarSkinTexture}
item={d.avatarItemTexture}
back={d.avatarBackTexture}
/>) : NO_CHUNI_PROFILE,
available: u => !!u?.chuni,
name: 'Chunithm Avatar'
},
'actaeon-status': {
render: s => (<ActaeonStatus status={s} className="w-full h-full" />),
minH: 225,
name: 'Actaeon Status'
}
};
type Layouts = { [k: string]: Layout[]; }
const centerX = ({ w, col }: { w: number, col: number; }) => Math.floor((col - w) / 2);
const getDefaultLayout = (user: UserPayload | undefined | null) => {
return Object.fromEntries(Object.values(COLS).map(col => {
const getXW = (w: number) => ({ w, x: centerX({ w, col }) });
const layouts: (Layout | null | undefined | false)[] = [
{ i: 'actaeon-status', y: 0, h: 1, ...getXW({
[6]: 6,
[7]: 5,
[9]: 3,
[11]: 3,
[13]: 3
}[col]!) },
ITEMS['chuni-nameplate'].available?.(user) && {
i: 'chuni-nameplate', y: 1, h: 1, ...getXW({
[6]: 6,
[7]: 5,
[9]: 5,
[11]: 5,
[13]: 3
}[col]!) }
];
return [col, layouts.filter(x => x) as Layout[]];
}))
};
const applyLayoutProps = (layouts: Layouts): Layouts => Object.fromEntries(Object.entries(layouts)
.map(([k, layout]) => [k, layout.map(l => {
const minH = (ITEMS as any)[l.i]?.minH;
if (minH) return ({ ...l, h: Math.max(l.h, minH), minH });
return l;
})]));
const serializeLayout = (layouts: Layouts) => {
const data = Object.fromEntries(Object.entries(layouts)
.map(([key, layout]) => {
let lastY = 0;
let yIndex = 0;
return [key, [...layout]
.sort((a, b) => a.y - b.y)
.map(({ x, y, w, h, i}) => {
if (y !== lastY) {
lastY = y;
yIndex++;
}
return { y: yIndex, x, w, i, h: ITEMS[i as keyof ItemData].aspect ? 1 : h };
})];
}));
return JSON.stringify(data);
};
type DashboardProps = {
chuniProfile?: ChuniUserData,
serverStatus: ServerStatus,
dashboard?: any
};
export const Dashboard = ({ chuniProfile, serverStatus, dashboard: initialDashboard }: DashboardProps) => {
const user = useUser();
const [layouts, setLayouts] = useState<Layouts>(applyLayoutProps(initialDashboard ?? getDefaultLayout(user)));
const layoutRestore = useRef(structuredClone(layouts));
const [width, setWidth] = useState(0);
const [resizingWindow, setResizingWindow] = useState(true);
const [mounted, setMounted] = useState(false);
const [overrideCol, setOverrideCol] = useState<null | number>(null);
const [overrideWidth, setOverrideWidth] = useState<null | number>(null);
const [editing, setEditing] = useState(false);
const lastTimeout = useRef<number | undefined>(undefined);
const containerRef = useRef<HTMLElement | null>(null);
const breakpoint = useBreakpoint();
const trueCol = COLS[breakpoint ?? 'xs'];
const col = overrideCol ?? trueCol;
const margin = col === COLS.xs ? [0, 10] : [10, 10];
const marginClass = col === COLS.xs ? 'py-[5px]' : 'p-[5px]';
const displayWidth = Math.min(overrideWidth ?? width, width);
const reflowItemAspect = useCallback((keepWidth: boolean, ...items: GridLayout.Layout[]) => {
if (!displayWidth) return items;
const colWidth = calculateUtils.calcGridColWidth({
margin: [0, 0],
containerPadding: [0, 0],
cols: col,
containerWidth: displayWidth
});
items.forEach(layoutItem => {
const itemAspect = ITEMS[layoutItem.i as keyof typeof ITEMS]?.aspect;
if (!itemAspect) return;
const aspect = (itemAspect[0]) / (itemAspect[1]);
if (keepWidth) {
layoutItem.h = Math.max(Math.round((layoutItem.w * colWidth + margin[0]) / aspect + margin[1]), 1);
return;
}
layoutItem.w = Math.min(Math.max(Math.round((layoutItem.h * aspect + margin[0]) / colWidth), 1), col);
const itemWidth = colWidth * layoutItem.w;
layoutItem.h = Math.max(Math.round(itemWidth / aspect + margin[1]), 1);
});
return items;
}, [displayWidth, col, margin[0], margin[1]]);
const handleResize = useCallback(((l, oldLayoutItem, layoutItem, placeholder) => {
reflowItemAspect(oldLayoutItem.w !== layoutItem.w, layoutItem, placeholder);
}) as ItemCallback, [reflowItemAspect]);
useEffect(() => {
setLayouts(l => ({ ...l, [col]: reflowItemAspect(true, ...l[col].map(i => ({ ...i }))) }));
}, [reflowItemAspect, setLayouts, col]);
useWindowListener('resize', () => {
setWidth(containerRef.current?.clientWidth!);
setResizingWindow(true);
clearTimeout(lastTimeout.current);
lastTimeout.current = setTimeout(() => {
setResizingWindow(false);
}, 100) as any as number;
});
useEffect(() => {
setWidth(containerRef.current?.clientWidth!);
setMounted(true);
const timeout = setTimeout(() => setResizingWindow(false), 100);
return () => clearTimeout(timeout);
}, []);
const children = useMemo(() => {
return layouts[col.toString()].map(layout => {
let rendered: ReactNode = null;
const item = ITEMS[layout.i as keyof typeof ITEMS];
if (!item) return null;
if (layout.i === 'chuni-nameplate')
rendered = ITEMS['chuni-nameplate'].render(chuniProfile);
else if (layout.i === 'chuni-avatar')
rendered = ITEMS['chuni-avatar'].render(chuniProfile);
else if (layout.i === 'actaeon-status')
rendered = ITEMS['actaeon-status'].render(serverStatus);
if (rendered === null) return null;
return (<div key={layout.i} className={`${marginClass} ${editing ? 'bg-gray-500/25 select-none' : ''} ${resizingWindow ? '!transition-none' : ''}`}>
<div className={`w-full max-h-full ${item.aspect ? '' : 'h-full'}`}
style={item.aspect ? { aspectRatio: `${item.aspect[0]} / ${item.aspect[1]}` } : undefined}>
{rendered}
</div>
</div>);
});
}, [layouts, col, marginClass, editing, resizingWindow, chuniProfile, serverStatus]);
const headerButtons = useMemo(() => {
if (!editing)
return (<Tooltip content="Edit layout">
<Button isIconOnly radius="full" variant="light" onPress={() => {
layoutRestore.current = structuredClone(layouts);
setEditing(true);
}}>
<PencilIcon className="h-1/2" />
</Button>
</Tooltip>);
const usedItems = new Set(Object.values(layouts).flatMap(l => l.map(item => item.i)) as (keyof ItemData)[]);
const addItem = (item: keyof ItemData) => {
setLayouts(layouts => Object.fromEntries(Object.entries(layouts).map(([key, items]) => {
const maxY = Math.max(...items.map(i => i.y), -1);
return [key, [...items, ...reflowItemAspect(true, { x: 0, y: maxY + 1, h: 1, w: 1, i: item })]];
})));
};
const removeItem = (item: keyof ItemData) => {
setLayouts(layouts => Object.fromEntries(Object.entries(layouts)
.map(([key, items]) => [key, items.filter(i => i.i !== item)])))
};
const availablePreviews = [...OVERRIDE_COLS].filter(([col]) => col < trueCol);
const previewItems = [
(<DropdownItem key="none" onPress={() => { setOverrideCol(null); setOverrideWidth(null); }}>
None
</DropdownItem>),
...availablePreviews.map(([col, { name, width }]) =>
(<DropdownItem key={col.toString()} onPress={() => {
setOverrideCol(col);
setOverrideWidth(width);
}}>
{name}
</DropdownItem>)
)
];
return (<>
{(!!availablePreviews.length || overrideCol !== null) && <Dropdown>
<Tooltip content="Preview Device">
<div>
<DropdownTrigger>
<Button isIconOnly radius="full">
<DevicePhoneMobileIcon className="h-1/2" />
</Button>
</DropdownTrigger>
</div>
</Tooltip>
<DropdownMenu selectedKeys={overrideCol ? new Set([overrideCol.toString()]) : new Set(['none'])} selectionMode="single">
{previewItems}
</DropdownMenu>
</Dropdown>}
<Dropdown>
<Tooltip content="Modify Items">
<div>
<DropdownTrigger>
<Button isIconOnly radius="full">
<SquaresPlusIcon className="h-1/2" />
</Button>
</DropdownTrigger>
</div>
</Tooltip>
<DropdownMenu selectionMode="multiple" selectedKeys={usedItems}>
{(Object.entries(ITEMS) as Entries<typeof ITEMS>)
.filter(([k, i]) => i.available ? i.available(user) : true)
.map(([key, item]) => (<DropdownItem key={key}
onPress={() => usedItems.has(key) ? removeItem(key) : addItem(key)}>
{item.name}
</DropdownItem>))}
</DropdownMenu>
</Dropdown>
<Tooltip content="Discard Changes">
<Button isIconOnly radius="full" variant="light" color="danger" className="ml-3"
onPress={() => {
setOverrideCol(null);
setOverrideWidth(null);
setEditing(false);
setLayouts(Object.fromEntries(Object.entries(layoutRestore.current).map(([k, v]) => [
k, reflowItemAspect(true, ...v.map(i => ({ ...i })))
])));
}}>
<XMarkIcon className="h-1/2" />
</Button>
</Tooltip>
<Tooltip content="Save">
<Button isIconOnly radius="full" color="primary" onPress={() => {
setOverrideCol(null);
setOverrideWidth(null);
setEditing(false);
setDashboard(serializeLayout(layouts));
}}>
<SaveIcon className="h-1/2" />
</Button>
</Tooltip>
</>);
}, [trueCol, overrideCol, editing, setEditing, setLayouts, layouts, reflowItemAspect, user, layoutRestore]);
const handle = (<div className="h-full py-2 px-3 w-px cursor-pointer relative">
<Divider orientation="vertical" />
<div className="absolute flex top-1/2 left-0.5">
<ChevronRightIcon className="w-3 -mr-1 text-gray-400" />
<ChevronLeftIcon className="w-3 text-gray-400" />
</div>
</div>);
const handlePreviewResize = (_: any, data: ResizeCallbackData) => {
const newWidth = width - 2 * data.size.width;
if (newWidth < 320) return;
const [col] = [...OVERRIDE_COLS].findLast(([col, { breakpoint }]) => newWidth >= breakpoint)!;
setOverrideCol(col);
setOverrideWidth(newWidth);
};
const showResize = mounted && (overrideCol !== null || overrideWidth !== null);
return (<main className="flex w-full h-full">
{showResize && <Resizable width={(width - displayWidth) / 2} axis="x"
resizeHandles={['e']}
handle={handle}
onResizeStart={() => setResizingWindow(true)}
onResizeStop={() => setResizingWindow(false)}
onResize={handlePreviewResize}>
<div className={`flex-grow flex justify-end bg-gray-400/25 h-full w-2 sm:-ml-5 ${overrideCol === COLS.xs ? '' : 'sm:mr-5'}`} />
</Resizable>}
<section className={`pb-4 overflow-hidden h-full w-full flex-shrink-0 ${mounted ? '' : 'opacity-0'}`} ref={containerRef}
style={overrideCol ? { maxWidth: `${displayWidth}px` } : undefined}>
<header className="px-4 font-semibold text-2xl h-12 mb-2 flex items-center gap-1.5">
<span className="mr-auto">Dashboard</span>
{headerButtons}
</header>
<Divider className="mb-2" />
<GridLayout className="relative"
containerPadding={[0, 0]}
margin={[0, 0]}
cols={col}
rowHeight={1}
width={displayWidth}
layout={[...layouts[col.toString()]].map(l => ({ ...l }))}
isDraggable={editing}
isResizable={editing}
onResize={handleResize}
onLayoutChange={l => setLayouts(lay => ({ ...lay, [col]: l }))}>
{children}
</GridLayout>
</section>
{showResize && <Resizable width={(width - displayWidth) / 2} axis="x"
resizeHandles={['w']}
handle={handle}
onResizeStart={() => setResizingWindow(true)}
onResizeStop={() => setResizingWindow(false)}
onResize={handlePreviewResize}>
<div className={`flex-grow bg-gray-400/25 h-full sm:-mr-5 ${overrideCol === COLS.xs ? '' : 'sm:ml-5'}`} />
</Resizable>}
</main>)
};

View File

@ -1,3 +1,23 @@
export default function DashboardPage() {
return <div>dashboard</div>;
import { getDashboard } from '@/data/dashboard';
import { Dashboard } from './dashboard';
import { getUser } from '@/actions/auth';
import { getUserData } from '@/actions/chuni/profile';
import { getServerStatus } from '@/data/status';
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const [user, status] = await Promise.all([
getUser(),
getServerStatus()
]);
if (!user) return (<Dashboard serverStatus={status} />)
const [chuniProfile, dashboard] = await Promise.all([
getUserData(user),
getDashboard(user)
]);
return (<Dashboard chuniProfile={chuniProfile} serverStatus={status} dashboard={dashboard} />);
}

View File

@ -77,6 +77,16 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
onSwipeStart: e => {
if (e.dir === 'Down' || e.dir === 'Up') return;
let parent: HTMLElement | null = e.event.target as HTMLElement;
while (parent) {
const data = parent.dataset;
if (data?.slot === 'thumb' || data?.dragging ||
parent?.classList?.contains('react-draggable-dragging') ||
parent?.classList?.contains('react-resizable-handle'))
return;
parent = parent.parentElement;
}
const data = (e.event.target as HTMLElement)?.dataset;
if (data?.slot === 'thumb' || data?.dragging)
return;

View File

@ -0,0 +1,18 @@
import { ServerStatus } from '@/data/status';
import { CHUNI_VERSIONS } from '@/types/game-versions';
import { Card, CardBody, CardHeader, Divider } from '@nextui-org/react';
export const ActaeonStatus = ({ status, className }: { status: ServerStatus, className?: string }) => {
return (<Card className={`${className} px-2 py-1`}>
<CardHeader className="font-semibold text-lg md:text-2xl pb-3">
Actaeon Status
</CardHeader>
<Divider />
<CardBody className="pt-2 flex flex-col flex-wrap gap-0.5 md:text-lg overflow-hidden">
<span><span className="font-semibold">User Count: </span>{status.userCount}</span>
{!!status.teamCount && <span><span className="font-semibold">Team Count: </span>{status.teamCount}</span>}
{!!status.arcadeCount && <span><span className="font-semibold">Arcade Count: </span>{status.arcadeCount}</span>}
{status.chuniVersion !== null && <span><span className="font-semibold">Latest Chunithm Version: </span>{CHUNI_VERSIONS[status.chuniVersion]}</span>}
</CardBody>
</Card>)
};

View File

@ -3,12 +3,12 @@ import { ChuniPenguinIcon } from './chuni-penguin-icon';
export const ChuniNoProfile = () => {
return (<section className="w-full h-full flex flex-col items-center justify-center gap-3">
<div className="w-1/2 max-w-72 aspect-square relative">
<div className="w-1/2 max-w-72 max-h-[50%] aspect-square relative">
<div className="absolute inset-0 w-full h-full flex items-center justify-center">
<ChuniPenguinIcon className="h-[70%]" />
</div>
<NoSymbolIcon className="w-full h-full" />
</div>
<header className="mb-8">You don&apos;t have a Chunithm profile.</header>
<header>You don&apos;t have a Chunithm profile.</header>
</section>)
};

16
src/data/dashboard.ts Normal file
View File

@ -0,0 +1,16 @@
import { getUser } from '@/actions/auth';
import { db } from '@/db';
import { UserPayload } from '@/types/user';
export const getDashboard = async (user: UserPayload | undefined | null) => {
if (!user) return null;
const dashboard = await db.selectFrom('actaeon_user_ext')
.where('userId', '=', user.id)
.select('dashboard')
.executeTakeFirst();
try {
if (dashboard?.dashboard)
return JSON.parse(dashboard.dashboard);
} catch { }
return null;
};

30
src/data/status.ts Normal file
View File

@ -0,0 +1,30 @@
import { db } from '@/db';
import { ExpressionBuilder } from 'kysely';
import { IsEqual } from 'type-fest';
type TablesWithVersion<DB> = {
[K in keyof DB]: DB[K] extends ({ version: number }) ? IsEqual<DB[K]['version'], number> extends true ? K : never : never
}[keyof DB];
const selectLatestVersion = <DB, TB extends (keyof DB) & string & TablesWithVersion<DB>, A extends string>(eb: ExpressionBuilder<DB, never>, tb: TB, as: A) => {
return eb.selectFrom(tb)
.select(({ fn }) => fn.max<number>('version' as any).as('v'))
.as(as);
};
const selectCount = <DB, TB extends (keyof DB) & string, A extends string>(eb: ExpressionBuilder<DB, never>, tb: TB, as: A) => {
return eb.selectFrom(tb)
.select(({ fn }: ExpressionBuilder<any, any>) => fn.countAll<number>().as('c'))
.as(as);
}
export const getServerStatus = async () => {
return db.selectNoFrom(eb => [
selectCount(eb, 'aime_user', 'userCount'),
selectCount(eb, 'actaeon_teams', 'teamCount'),
selectCount(eb, 'arcade', 'arcadeCount'),
selectLatestVersion(eb, 'chuni_static_music', 'chuniVersion')
])
.executeTakeFirstOrThrow();
};
export type ServerStatus = Awaited<ReturnType<typeof getServerStatus>>;

1
src/types/db.d.ts vendored
View File

@ -72,6 +72,7 @@ export interface ActaeonTeams {
}
export interface ActaeonUserExt {
dashboard: string | null;
homepage: string | null;
team: string | null;
userId: number;

View File

@ -0,0 +1,18 @@
export const CHUNI_VERSIONS = [
'CHUNITHM',
'PLUS',
'AIR',
'AIR PLUS',
'STAR',
'STAR PLUS',
'AMAZON',
'AMAZON PLUS',
'CRYSTAL',
'CRYSTAL PLUS',
'PARADISE',
'NEW!!',
'NEW PLUS!!',
'SUN',
'SUN PLUS',
'LUMINOUS'
];

15
src/types/react-grid-layout/index.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import 'react-grid-layout';
type CalcGridColParams = {
margin: [number, number],
containerPadding: [number, number],
containerWidth: number,
cols: number;
};
const calcGridColWidth: (pos: CalcGridColParams) => number;
const calcGridItemWHPx: (gridUnits: number, colOrRowSize: number, marginPx: number) => number;
declare module 'react-grid-layout' {
export const calculateUtils = { calcGridColWidth, calcGridItemWHPx };
}

View File

@ -1,6 +1,6 @@
import { AimeUser, DB } from '@/types/db';
export type DBUserPayload = Omit<AimeUser, 'password'> & Omit<DB['actaeon_user_ext'], 'userId'> & {
export type DBUserPayload = Omit<AimeUser, 'password'> & Omit<DB['actaeon_user_ext'], 'userId' | 'dashboard'> & {
chuni: boolean
};