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",
"version": "0.0.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vertex",
"version": "0.0.1",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@mantine/core": "^7.16.3",
"@mantine/hooks": "^7.16.3",
@@ -16,6 +17,7 @@
"jotai": "^2.11.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.5",
"reset.css": "^2.0.2",
"unsplash-js": "^7.0.19"
},
@@ -1414,6 +1416,11 @@
"@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": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -1896,6 +1903,14 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"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": {
"version": "7.0.6",
"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": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -3151,6 +3189,11 @@
"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": {
"version": "2.0.0",
"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",
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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