forked from sk1982/actaeon
add dashboard
This commit is contained in:
parent
f4c9804ded
commit
3f486b81fd
53
migrations/20240402044205-add-dashboard-to-user-ext.js
Normal file
53
migrations/20240402044205-add-dashboard-to-user-ext.js
Normal 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
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE actaeon_user_ext
|
||||
DROP COLUMN dashboard;
|
@ -0,0 +1,2 @@
|
||||
ALTER TABLE actaeon_user_ext
|
||||
ADD COLUMN dashboard MEDIUMTEXT;
|
82
package-lock.json
generated
82
package-lock.json
generated
@ -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",
|
||||
|
@ -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
16
src/actions/dashboard.ts
Normal 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');
|
||||
};
|
433
src/app/(with-header)/dashboard/dashboard.tsx
Normal file
433
src/app/(with-header)/dashboard/dashboard.tsx
Normal 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>)
|
||||
};
|
@ -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} />);
|
||||
}
|
||||
|
@ -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;
|
||||
|
18
src/components/actaeon-status.tsx
Normal file
18
src/components/actaeon-status.tsx
Normal 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>)
|
||||
};
|
@ -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't have a Chunithm profile.</header>
|
||||
<header>You don't have a Chunithm profile.</header>
|
||||
</section>)
|
||||
};
|
||||
|
16
src/data/dashboard.ts
Normal file
16
src/data/dashboard.ts
Normal 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
30
src/data/status.ts
Normal 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
1
src/types/db.d.ts
vendored
@ -72,6 +72,7 @@ export interface ActaeonTeams {
|
||||
}
|
||||
|
||||
export interface ActaeonUserExt {
|
||||
dashboard: string | null;
|
||||
homepage: string | null;
|
||||
team: string | null;
|
||||
userId: number;
|
||||
|
18
src/types/game-versions.ts
Normal file
18
src/types/game-versions.ts
Normal 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
15
src/types/react-grid-layout/index.d.ts
vendored
Normal 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 };
|
||||
}
|
@ -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
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user