Add table view

This commit is contained in:
Georgi Gardev
2025-02-16 19:28:05 +02:00
parent 87cce930c9
commit 477b5b8b84
12 changed files with 270 additions and 52 deletions

52
package-lock.json generated
View File

@@ -1,12 +1,13 @@
{ {
"name": "vertex", "name": "vertex",
"version": "0.0.1", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vertex", "name": "vertex",
"version": "0.0.1", "version": "0.1.0",
"license": "MIT",
"dependencies": { "dependencies": {
"@mantine/core": "^7.16.3", "@mantine/core": "^7.16.3",
"@mantine/hooks": "^7.16.3", "@mantine/hooks": "^7.16.3",
@@ -16,6 +17,7 @@
"jotai": "^2.11.3", "jotai": "^2.11.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^7.1.5",
"reset.css": "^2.0.2", "reset.css": "^2.0.2",
"unsplash-js": "^7.0.19" "unsplash-js": "^7.0.19"
}, },
@@ -1414,6 +1416,11 @@
"@babel/types": "^7.20.7" "@babel/types": "^7.20.7"
} }
}, },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -1896,6 +1903,14 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true "dev": true
}, },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3010,6 +3025,29 @@
} }
} }
}, },
"node_modules/react-router": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.5.tgz",
"integrity": "sha512-8BUF+hZEU4/z/JD201yK6S+UYhsf58bzYIDq2NS1iGpwxSXDu7F+DeGSkIXMFBuHZB21FSiCzEcUb18cQNdRkA==",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -3151,6 +3189,11 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3264,6 +3307,11 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}, },
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -3,7 +3,12 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"description": "Vertex A start page for selfhosted services.", "description": "Vertex A start page for selfhosted services.",
"keywords": ["startpage", "selfhosted", "dashboard", "vertex"], "keywords": [
"startpage",
"selfhosted",
"dashboard",
"vertex"
],
"author": "Georgi Gardev <georgi@gar.dev>", "author": "Georgi Gardev <georgi@gar.dev>",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -21,6 +26,7 @@
"jotai": "^2.11.3", "jotai": "^2.11.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router": "^7.1.5",
"reset.css": "^2.0.2", "reset.css": "^2.0.2",
"unsplash-js": "^7.0.19" "unsplash-js": "^7.0.19"
}, },

View File

@@ -1,4 +1,5 @@
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import { BrowserRouter, Route, Routes } from 'react-router';
import { Background as BackgroundComponent } from '~/components/ui/background'; import { Background as BackgroundComponent } from '~/components/ui/background';
import { useLoadBackgrounds } from './hooks/use-load-backgrounds'; import { useLoadBackgrounds } from './hooks/use-load-backgrounds';
import { IndexPage } from './pages/index-page'; import { IndexPage } from './pages/index-page';
@@ -9,7 +10,11 @@ export function App() {
return ( return (
<MantineProvider defaultColorScheme="dark"> <MantineProvider defaultColorScheme="dark">
<BackgroundComponent /> <BackgroundComponent />
<IndexPage /> <BrowserRouter>
<Routes>
<Route path="/" element={<IndexPage />} />
</Routes>
</BrowserRouter>
</MantineProvider> </MantineProvider>
); );
} }

View File

@@ -9,7 +9,7 @@ export type Settings = {
viewAsTable: boolean; viewAsTable: boolean;
useSavedBackgrounds: boolean; useSavedBackgrounds: boolean;
unsplashQuery: string; unsplashQuery: string;
viewMode: 'tile' | 'pill'; viewMode: 'tile' | 'pill' | 'table';
backgroundBlur: number; backgroundBlur: number;
}; };

View File

@@ -5,6 +5,7 @@ import { AppButton } from './app-button';
import { AppPill } from './app-pill'; import { AppPill } from './app-pill';
import style from './app-list.module.css'; import style from './app-list.module.css';
import { AppsTable } from './apps-table';
type AppListProps = { type AppListProps = {
category: string; category: string;
@@ -27,11 +28,15 @@ export function AppList({ category, apps, onReset }: AppListProps) {
</span> </span>
</h2> </h2>
)} )}
<div className={`${style.AppList} ${viewMode === 'tile' ? style.Tile : style.Pill}`}> {viewMode === 'table' ? (
{apps.length > 0 <AppsTable apps={apps} />
? apps.map((app) => <Component key={app.url} {...app} />) ) : (
: 'No apps found'} <div className={`${style.AppList} ${viewMode === 'tile' ? style.Tile : style.Pill}`}>
</div> {apps.length > 0
? apps.map((app) => <Component key={app.url} {...app} />)
: 'No apps found'}
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,6 @@
.Table {
background-color: var(--color-bg-dark);
backdrop-filter: var(--bg-blur);
color: var(--color-text);
border-radius: var(--border-radius-default);
}

View File

@@ -0,0 +1,99 @@
import { Anchor, Flex, Table } from '@mantine/core';
import { IconSortAscending } from '@tabler/icons-react';
import { JSX, useMemo, useState } from 'react';
import { getAppIcon } from '~/api/get-app-icon';
import { AppDefinition } from '~/types';
import style from './apps-table.module.css';
type AppsTableProps = {
apps: AppDefinition[];
};
export function AppsTable({ apps }: AppsTableProps): JSX.Element {
const [sortBy, setSortBy] = useState<'name' | 'url' | 'location' | 'shortcut'>('location');
const tableData = useMemo(
() =>
apps
.map((app) => ({
...app,
shortcut: app.shortcut ?? '',
}))
.sort((a, b) => {
const [serverA, portA] = a.location?.split(':') ?? ['', ''];
const [serverB, portB] = b.location?.split(':') ?? ['', ''];
const portCmp = Number(portA) - Number(portB);
if (!a[sortBy]) {
return 1;
}
if (!b[sortBy]) {
return -1;
}
if (sortBy === 'location') {
return serverA.localeCompare(serverB) || portCmp;
}
return a[sortBy].localeCompare(b[sortBy]);
}),
[apps, sortBy]
);
return (
<Table mt="xl" stickyHeader className={style.Table}>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Flex align="center" gap="xs">
Name <IconSortAscending size={16} onClick={() => setSortBy('name')} />
</Flex>
</Table.Th>
<Table.Th>
<Flex align="center" gap="xs">
URL <IconSortAscending size={16} onClick={() => setSortBy('url')} />
</Flex>
</Table.Th>
<Table.Th>
<Flex align="center" gap="xs">
Location <IconSortAscending size={16} onClick={() => setSortBy('location')} />
</Flex>
</Table.Th>
<Table.Th>
<Flex align="center" gap="xs">
Shortcut <IconSortAscending size={16} onClick={() => setSortBy('shortcut')} />
</Flex>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tableData.map((app) => (
<Table.Tr key={app.name}>
<Table.Td>
<Anchor href={app.url} target="_blank" rel="noopener noreferrer">
<Flex align="center" gap="xs">
{app.icon && (
<img
src={getAppIcon(app.icon, app.icon_source)}
alt={app.name}
style={{ width: '16px' }}
/>
)}
{app.name}
</Flex>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor href={app.url} target="_blank" rel="noopener noreferrer">
{app.url}
</Anchor>
</Table.Td>
<Table.Td>{app.location}</Table.Td>
<Table.Td>{app.shortcut}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,19 @@
.SidebarPage {
display: flex;
}
@media (max-width: 600px) {
.SidebarPage {
flex-direction: column;
}
}
.ContentWrapper {
display: flex;
align-items: flex-start;
gap: 2rem;
height: 100vh;
width: 100%;
overflow: scroll;
padding: 0 clamp(0.6rem, 5vw, 10rem);
}

View File

@@ -0,0 +1,28 @@
import { JSX, ReactNode } from 'react';
import { Sidebar, SidebarProps } from './sidebar';
import style from './layout.module.css';
type LayoutProps = {
isLoading: boolean;
children: ReactNode;
} & SidebarProps;
export function Layout({
isLoading,
categories,
activeCategory,
selectCategory,
children,
}: LayoutProps): JSX.Element {
return (
<main className={`${style.SidebarPage} ${isLoading ? style.Loading : ''}`}>
<Sidebar
categories={categories}
selectCategory={selectCategory}
activeCategory={activeCategory}
/>
<div className={style.ContentWrapper}>{children}</div>
</main>
);
}

View File

@@ -9,7 +9,7 @@ import { WeatherWidget } from '../widgets/weather-widget';
import { useLoadingTimeout } from '~/hooks/use-loading-timeout'; import { useLoadingTimeout } from '~/hooks/use-loading-timeout';
import style from './sidebar.module.css'; import style from './sidebar.module.css';
type SidebarProps = { export type SidebarProps = {
categories?: string[]; categories?: string[];
activeCategory?: string | null; activeCategory?: string | null;
selectCategory: (category: string, mode: 'permanent' | 'temporary') => void; selectCategory: (category: string, mode: 'permanent' | 'temporary') => void;

View File

@@ -55,11 +55,15 @@ export function Settings({ className }: SettingsProps) {
w="30%" w="30%"
value={appSettings.viewMode} value={appSettings.viewMode}
onChange={(value) => onChange={(value) =>
setAppSettings((prev) => ({ ...prev, viewMode: value as 'tile' | 'pill' })) setAppSettings((prev) => ({
...prev,
viewMode: value as 'tile' | 'pill' | 'table',
}))
} }
data={[ data={[
{ label: 'Tiles', value: 'tile' }, { label: 'Tiles', value: 'tile' },
{ label: 'Pills', value: 'pill' }, { label: 'Pills', value: 'pill' },
{ label: 'Table', value: 'table' },
]} ]}
/> />
</Flex> </Flex>

View File

@@ -4,13 +4,13 @@ import { useCategories } from '~/api/get-apps';
import { useSearchProviders } from '~/api/get-search-providers'; import { useSearchProviders } from '~/api/get-search-providers';
import { onNextBackgroundAtom, settingsAtom } from '~/atoms'; import { onNextBackgroundAtom, settingsAtom } from '~/atoms';
import { AppList } from '~/components/apps/app-list'; import { AppList } from '~/components/apps/app-list';
import { Sidebar } from '~/components/ui/sidebar';
import { CurrentImageWidget } from '~/components/widgets/current-image-widget'; import { CurrentImageWidget } from '~/components/widgets/current-image-widget';
import { DateWidget } from '~/components/widgets/date-widget'; import { DateWidget } from '~/components/widgets/date-widget';
import { SearchWidget } from '~/components/widgets/search-widget'; import { SearchWidget } from '~/components/widgets/search-widget';
import { useActiveCategory } from '~/hooks/use-active-category'; import { useActiveCategory } from '~/hooks/use-active-category';
import { useLoadingTimeout } from '~/hooks/use-loading-timeout'; import { useLoadingTimeout } from '~/hooks/use-loading-timeout';
import { Layout } from '~/components/ui/layout';
import style from './index-page.module.css'; import style from './index-page.module.css';
export function IndexPage() { export function IndexPage() {
@@ -73,46 +73,44 @@ export function IndexPage() {
} }
return ( return (
<main className={`${style.SidebarPage} ${isInitialLoading ? style.Loading : ''}`}> <Layout
<Sidebar isLoading={isInitialLoading}
categories={categories?.flatMap((c) => c.name)} categories={categories.flatMap((c) => c.name)}
selectCategory={selectCategory} activeCategory={activeCategoryName}
activeCategory={activeCategoryName} selectCategory={selectCategory}
/> >
<div className={style.ContentWrapper}> <div className={style.Widgets}>
<div className={style.Widgets}> {activeCategory ? (
{activeCategory ? ( <AppList
<AppList category={activeCategory.name}
category={activeCategory.name} apps={activeCategory.apps}
apps={activeCategory.apps} onReset={resetCategory}
onReset={resetCategory} />
/> ) : (
) : ( <>
<> <div className={style.MainWidgets}>
<div className={style.MainWidgets}> <DateWidget />
<DateWidget /> <SearchWidget
<SearchWidget term={searchTerm}
term={searchTerm} setTerm={setSearchTerm}
setTerm={setSearchTerm} providers={searchProviders}
providers={searchProviders} canOpenApp={searchResults.length > 0}
canOpenApp={searchResults.length > 0} onOpenApp={() => window.open(searchResults[0].url)}
onOpenApp={() => window.open(searchResults[0].url)} />
/> </div>
</div> {shouldSearchLocally ? (
{shouldSearchLocally ? ( <AppList
<AppList category="search results"
category="search results" apps={searchResults}
apps={searchResults} onReset={() => setSearchTerm('')}
onReset={() => setSearchTerm('')} />
/> ) : (
) : ( <AppList category="" apps={apps} />
<AppList category="" apps={apps} /> )}
)} </>
</> )}
)}
</div>
</div> </div>
{showImageDetails && <CurrentImageWidget loading={isInitialLoading} />} {showImageDetails && <CurrentImageWidget loading={isInitialLoading} />}
</main> </Layout>
); );
} }