diff --git a/package-lock.json b/package-lock.json index 2b7092e..47dfd5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f01c127..7ca3eec 100644 --- a/package.json +++ b/package.json @@ -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 ", "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" }, diff --git a/src/app.tsx b/src/app.tsx index ff28b2a..d640c34 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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 ( - + + + } /> + + ); } diff --git a/src/atoms.ts b/src/atoms.ts index 30c9f26..e8f76c5 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -9,7 +9,7 @@ export type Settings = { viewAsTable: boolean; useSavedBackgrounds: boolean; unsplashQuery: string; - viewMode: 'tile' | 'pill'; + viewMode: 'tile' | 'pill' | 'table'; backgroundBlur: number; }; diff --git a/src/components/apps/app-list.tsx b/src/components/apps/app-list.tsx index e0884f9..b6bb53d 100644 --- a/src/components/apps/app-list.tsx +++ b/src/components/apps/app-list.tsx @@ -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) { )} -
- {apps.length > 0 - ? apps.map((app) => ) - : 'No apps found'} -
+ {viewMode === 'table' ? ( + + ) : ( +
+ {apps.length > 0 + ? apps.map((app) => ) + : 'No apps found'} +
+ )} ); } diff --git a/src/components/apps/apps-table.module.css b/src/components/apps/apps-table.module.css new file mode 100644 index 0000000..4b502dd --- /dev/null +++ b/src/components/apps/apps-table.module.css @@ -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); +} diff --git a/src/components/apps/apps-table.tsx b/src/components/apps/apps-table.tsx new file mode 100644 index 0000000..4a70da1 --- /dev/null +++ b/src/components/apps/apps-table.tsx @@ -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 ( + + + + + + Name setSortBy('name')} /> + + + + + URL setSortBy('url')} /> + + + + + Location setSortBy('location')} /> + + + + + Shortcut setSortBy('shortcut')} /> + + + + + + {tableData.map((app) => ( + + + + + {app.icon && ( + {app.name} + )} + {app.name} + + + + + + {app.url} + + + {app.location} + {app.shortcut} + + ))} + +
+ ); +} diff --git a/src/components/ui/layout.module.css b/src/components/ui/layout.module.css new file mode 100644 index 0000000..a7e0de8 --- /dev/null +++ b/src/components/ui/layout.module.css @@ -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); +} diff --git a/src/components/ui/layout.tsx b/src/components/ui/layout.tsx new file mode 100644 index 0000000..d170d83 --- /dev/null +++ b/src/components/ui/layout.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 271c9d5..24eb3f4 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -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; diff --git a/src/components/widgets/settings.tsx b/src/components/widgets/settings.tsx index 2d99c56..332b5c9 100644 --- a/src/components/widgets/settings.tsx +++ b/src/components/widgets/settings.tsx @@ -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' }, ]} /> diff --git a/src/pages/index-page.tsx b/src/pages/index-page.tsx index a5a3ec0..abde341 100644 --- a/src/pages/index-page.tsx +++ b/src/pages/index-page.tsx @@ -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 ( -
- c.name)} - selectCategory={selectCategory} - activeCategory={activeCategoryName} - /> -
-
- {activeCategory ? ( - - ) : ( - <> -
- - 0} - onOpenApp={() => window.open(searchResults[0].url)} - /> -
- {shouldSearchLocally ? ( - setSearchTerm('')} - /> - ) : ( - - )} - - )} -
+ c.name)} + activeCategory={activeCategoryName} + selectCategory={selectCategory} + > +
+ {activeCategory ? ( + + ) : ( + <> +
+ + 0} + onOpenApp={() => window.open(searchResults[0].url)} + /> +
+ {shouldSearchLocally ? ( + setSearchTerm('')} + /> + ) : ( + + )} + + )}
{showImageDetails && } -
+ ); }