Add table view
This commit is contained in:
52
package-lock.json
generated
52
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export type Settings = {
|
||||
viewAsTable: boolean;
|
||||
useSavedBackgrounds: boolean;
|
||||
unsplashQuery: string;
|
||||
viewMode: 'tile' | 'pill';
|
||||
viewMode: 'tile' | 'pill' | 'table';
|
||||
backgroundBlur: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
6
src/components/apps/apps-table.module.css
Normal file
6
src/components/apps/apps-table.module.css
Normal 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);
|
||||
}
|
||||
99
src/components/apps/apps-table.tsx
Normal file
99
src/components/apps/apps-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/ui/layout.module.css
Normal file
19
src/components/ui/layout.module.css
Normal 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);
|
||||
}
|
||||
28
src/components/ui/layout.tsx
Normal file
28
src/components/ui/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user