diff --git a/migrations/20240402044205-add-dashboard-to-user-ext.js b/migrations/20240402044205-add-dashboard-to-user-ext.js new file mode 100644 index 0000000..6641c6f --- /dev/null +++ b/migrations/20240402044205-add-dashboard-to-user-ext.js @@ -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 +}; diff --git a/migrations/sqls/20240402044205-add-dashboard-to-user-ext-down.sql b/migrations/sqls/20240402044205-add-dashboard-to-user-ext-down.sql new file mode 100644 index 0000000..deb74d9 --- /dev/null +++ b/migrations/sqls/20240402044205-add-dashboard-to-user-ext-down.sql @@ -0,0 +1,2 @@ +ALTER TABLE actaeon_user_ext +DROP COLUMN dashboard; diff --git a/migrations/sqls/20240402044205-add-dashboard-to-user-ext-up.sql b/migrations/sqls/20240402044205-add-dashboard-to-user-ext-up.sql new file mode 100644 index 0000000..f3ce70d --- /dev/null +++ b/migrations/sqls/20240402044205-add-dashboard-to-user-ext-up.sql @@ -0,0 +1,2 @@ +ALTER TABLE actaeon_user_ext +ADD COLUMN dashboard MEDIUMTEXT; diff --git a/package-lock.json b/package-lock.json index 381abdf..2780187 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dcfe316..a548d27 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/actions/dashboard.ts b/src/actions/dashboard.ts new file mode 100644 index 0000000..2cfc759 --- /dev/null +++ b/src/actions/dashboard.ts @@ -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'); +}; diff --git a/src/app/(with-header)/dashboard/dashboard.tsx b/src/app/(with-header)/dashboard/dashboard.tsx new file mode 100644 index 0000000..d804090 --- /dev/null +++ b/src/app/(with-header)/dashboard/dashboard.tsx @@ -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 = { + aspect?: [number, number], + render: IsEqual 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 = ; + +const ITEMS: { [K in keyof ItemData]: Item } = { + 'chuni-nameplate': { + aspect: [576, 228], + render: d => d ? () : NO_CHUNI_PROFILE, + available: u => !!u?.chuni, + name: 'Chunithm Nameplate' + }, + 'chuni-avatar': { + aspect: [544, 588], + render: d => d ? () : NO_CHUNI_PROFILE, + available: u => !!u?.chuni, + name: 'Chunithm Avatar' + }, + 'actaeon-status': { + render: s => (), + 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(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); + const [overrideWidth, setOverrideWidth] = useState(null); + const [editing, setEditing] = useState(false); + const lastTimeout = useRef(undefined); + const containerRef = useRef(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 (
+
+ {rendered} +
+
); + }); + }, [layouts, col, marginClass, editing, resizingWindow, chuniProfile, serverStatus]); + + const headerButtons = useMemo(() => { + if (!editing) + return ( + + ); + + 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 = [ + ( { setOverrideCol(null); setOverrideWidth(null); }}> + None + ), + ...availablePreviews.map(([col, { name, width }]) => + ( { + setOverrideCol(col); + setOverrideWidth(width); + }}> + {name} + ) + ) + ]; + + return (<> + {(!!availablePreviews.length || overrideCol !== null) && + +
+ + + +
+
+ + {previewItems} + +
} + + + +
+ + + +
+
+ + {(Object.entries(ITEMS) as Entries) + .filter(([k, i]) => i.available ? i.available(user) : true) + .map(([key, item]) => ( usedItems.has(key) ? removeItem(key) : addItem(key)}> + {item.name} + ))} + +
+ + + + + + + + ); + }, [trueCol, overrideCol, editing, setEditing, setLayouts, layouts, reflowItemAspect, user, layoutRestore]); + + const handle = (
+ +
+ + +
+
); + + 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 (
+ {showResize && setResizingWindow(true)} + onResizeStop={() => setResizingWindow(false)} + onResize={handlePreviewResize}> +
+ } +
+
+ Dashboard + {headerButtons} +
+ + ({ ...l }))} + isDraggable={editing} + isResizable={editing} + onResize={handleResize} + onLayoutChange={l => setLayouts(lay => ({ ...lay, [col]: l }))}> + {children} + + +
+ {showResize && setResizingWindow(true)} + onResizeStop={() => setResizingWindow(false)} + onResize={handlePreviewResize}> +
+ } +
) +}; diff --git a/src/app/(with-header)/dashboard/page.tsx b/src/app/(with-header)/dashboard/page.tsx index 2190c09..1dfbd46 100644 --- a/src/app/(with-header)/dashboard/page.tsx +++ b/src/app/(with-header)/dashboard/page.tsx @@ -1,3 +1,23 @@ -export default function DashboardPage() { - return
dashboard
; +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 () + + const [chuniProfile, dashboard] = await Promise.all([ + getUserData(user), + getDashboard(user) + ]); + + return (); } diff --git a/src/app/(with-header)/header-sidebar.tsx b/src/app/(with-header)/header-sidebar.tsx index 3e67294..82a7bfb 100644 --- a/src/app/(with-header)/header-sidebar.tsx +++ b/src/app/(with-header)/header-sidebar.tsx @@ -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; diff --git a/src/components/actaeon-status.tsx b/src/components/actaeon-status.tsx new file mode 100644 index 0000000..0ef7954 --- /dev/null +++ b/src/components/actaeon-status.tsx @@ -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 ( + + Actaeon Status + + + + User Count: {status.userCount} + {!!status.teamCount && Team Count: {status.teamCount}} + {!!status.arcadeCount && Arcade Count: {status.arcadeCount}} + {status.chuniVersion !== null && Latest Chunithm Version: {CHUNI_VERSIONS[status.chuniVersion]}} + + ) +}; diff --git a/src/components/chuni/no-profile.tsx b/src/components/chuni/no-profile.tsx index 952c9b6..a8846fa 100644 --- a/src/components/chuni/no-profile.tsx +++ b/src/components/chuni/no-profile.tsx @@ -3,12 +3,12 @@ import { ChuniPenguinIcon } from './chuni-penguin-icon'; export const ChuniNoProfile = () => { return (
-
+
-
You don't have a Chunithm profile.
+
You don't have a Chunithm profile.
) }; diff --git a/src/data/dashboard.ts b/src/data/dashboard.ts new file mode 100644 index 0000000..b0549c9 --- /dev/null +++ b/src/data/dashboard.ts @@ -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; +}; diff --git a/src/data/status.ts b/src/data/status.ts new file mode 100644 index 0000000..5486ec0 --- /dev/null +++ b/src/data/status.ts @@ -0,0 +1,30 @@ +import { db } from '@/db'; +import { ExpressionBuilder } from 'kysely'; +import { IsEqual } from 'type-fest'; + +type TablesWithVersion = { + [K in keyof DB]: DB[K] extends ({ version: number }) ? IsEqual extends true ? K : never : never +}[keyof DB]; + +const selectLatestVersion = , A extends string>(eb: ExpressionBuilder, tb: TB, as: A) => { + return eb.selectFrom(tb) + .select(({ fn }) => fn.max('version' as any).as('v')) + .as(as); +}; +const selectCount = (eb: ExpressionBuilder, tb: TB, as: A) => { + return eb.selectFrom(tb) + .select(({ fn }: ExpressionBuilder) => fn.countAll().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>; diff --git a/src/types/db.d.ts b/src/types/db.d.ts index 1a96fb1..324a798 100644 --- a/src/types/db.d.ts +++ b/src/types/db.d.ts @@ -72,6 +72,7 @@ export interface ActaeonTeams { } export interface ActaeonUserExt { + dashboard: string | null; homepage: string | null; team: string | null; userId: number; diff --git a/src/types/game-versions.ts b/src/types/game-versions.ts new file mode 100644 index 0000000..6eb5ee2 --- /dev/null +++ b/src/types/game-versions.ts @@ -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' +]; diff --git a/src/types/react-grid-layout/index.d.ts b/src/types/react-grid-layout/index.d.ts new file mode 100644 index 0000000..ca71ad9 --- /dev/null +++ b/src/types/react-grid-layout/index.d.ts @@ -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 }; +} diff --git a/src/types/user.ts b/src/types/user.ts index c68b639..6fea772 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,6 +1,6 @@ import { AimeUser, DB } from '@/types/db'; -export type DBUserPayload = Omit & Omit & { +export type DBUserPayload = Omit & Omit & { chuni: boolean };