diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..7c72cafe --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +pocketbase/ \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c34cdfac..08226800 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,13 +1,13 @@ -name: Build and Deploy API +name: Build Docker Images on: push: branches: - - main + - dev jobs: build-and-deploy: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Checkout code @@ -32,4 +32,13 @@ jobs: file: ./Dockerfile push: true platforms: linux/amd64,linux/arm64 - tags: ${{ secrets.DOCKER_USERNAME }}/dashwise:dev \ No newline at end of file + tags: ${{ secrets.DOCKER_USERNAME }}/dashwise:dev + + - name: Build and Push Multi-Arch Pocketbase Docker image + uses: docker/build-push-action@v5 + with: + context: ./pocketbase/ + file: ./pocketbase/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ secrets.DOCKER_USERNAME }}/dashwise-pb:dev \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d3497840 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Build Docker Images (Release) + +on: + push: + branches: + - release + +jobs: + build-and-deploy: + runs-on: self-hosted + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and Push Multi-Arch Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ secrets.DOCKER_USERNAME }}/dashwise:${{ secrets.VERSION }} + ${{ secrets.DOCKER_USERNAME }}/dashwise:latest + ${{ secrets.DOCKER_USERNAME }}/dashwise:stable + + - name: Build and Push Multi-Arch Pocketbase Docker image + uses: docker/build-push-action@v5 + with: + context: ./pocketbase/ + file: ./pocketbase/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ secrets.DOCKER_USERNAME }}/dashwise-pb:${{ secrets.VERSION }} + ${{ secrets.DOCKER_USERNAME }}/dashwise-pb:latest + ${{ secrets.DOCKER_USERNAME }}/dashwise-pb:stable diff --git a/.gitignore b/.gitignore index 5242e063..4e9e7538 100755 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ /coverage #pocketbase data -/pocketbase +/pocketbase/pb_data # next.js /.next/ diff --git a/README.md b/README.md index c20edcd9..b1d7e0e6 100755 --- a/README.md +++ b/README.md @@ -2,9 +2,46 @@ I've been self hosting for a while but did not find a dashboard that suits my needs and that I like the look of. This is my attempt to solving that. +> **Disclaimer:** This project is still under development. Therefore, only the working features of the app are listed in the features section. Integration with additional services is planned, but will not be available until widget implementation is complete. + +## Screenshot +Screenshot 2025-10-22 at 08 03 40 + +## Features +- Links: store your most important links for quick access +- Glanceables: Bits of one-line information next to the clock +- Spotlight-like Search: Hit Ctrl+K from your dashboard, and you'll be able to search your links and integrations or use bangs for search engines specified in settings. +- Integrations: directly integrates with your favourite self hosted apps. For now only Karakeep is supported but more integrations are planned. + +## Installation +Grab the docker compose file (docker-compose.prod.yaml), edit env vars, pull, deploy. That's it. + +## Configuration +You can use the following environment variables: + +| Name | Required | Default Value | Description | +| --- | --- | --- | --- | +| NEXT_PUBLIC_PB_URL | Yes | `http://pocketbase:8090` | URL of the PocketBase instance | +| NEXT_PUBLIC_INTEGRATIONS_ENABLE_SSL | No | `false` | Enable SSL for integrations | +| PB_ADMIN_EMAIL | Yes | `default@dashwise.local` | Email of the PocketBase admin user | +| PB_ADMIN_PASSWORD | Yes | `DashwiseIsAwesome` | Password of the PocketBase admin user | +| NEXT_PUBLIC_APP_URL | Yes | `http://localhost:3000` | URL of the application | +| NEXT_PUBLIC_ENABLE_SSO | No | `false` | Enable Single Sign-On (SSO) | +| NEXT_PUBLIC_DEFAULT_BG_URL | No | `/dashboard-wallpaper.png` | Default background URL | + +## Tech Stack +Frontend, API Layer: Next.js +Backend: Pocketbase + +## How it works +On signup, a json config is created for each user. +It's available to the frontend via a GET request to /api/v1/config. +Accessing it is handled by the ConfigContext. + ## Open Source Projects that make dashwise possible -Self host icons -Nextjs, Shadcn +[Selfh.st icons](https://github.com/selfhst/icons), +[Font Awesome](https://fontawesome.com), +[Nextjs](https://github.com/vercel/next.js), [Shadcn](https://github.com/shadcn-ui/ui) ## Contributions -Feel free to contribute! I'll probably create a roadmap soon. \ No newline at end of file +Feel free to contribute! I'll probably create a more detailed roadmap soon. diff --git a/app/(config-wrapper)/home/page.tsx b/app/(config-wrapper)/home/page.tsx index 2e578799..8ad01bcc 100644 --- a/app/(config-wrapper)/home/page.tsx +++ b/app/(config-wrapper)/home/page.tsx @@ -1,6 +1,19 @@ +"use client"; import DashboardLayoutComponent from "@/components/dashboard/DashboardLayout"; +import { useEffect } from "react"; export default function DashboardPage() { + useEffect(() => { + const token = document.cookie + .split('; ') + .find(row => row.startsWith('pb_token=')) + ?.split('=')[1]; + + if (token) { + localStorage.setItem('pb_token', token); + } + }, []); + return ( ); diff --git a/app/(config-wrapper)/settings/account/page.tsx b/app/(config-wrapper)/settings/account/page.tsx index 40428c15..5673708e 100644 --- a/app/(config-wrapper)/settings/account/page.tsx +++ b/app/(config-wrapper)/settings/account/page.tsx @@ -29,6 +29,7 @@ import { useRouter } from "next/navigation" import { DialogDescription } from "@radix-ui/react-dialog" import ExportConfigDialog from "@/components/settings/ExportConfigDialog" import { useConfig } from "@/context/ConfigContext" +import ImportConfigDialog from "@/components/settings/ImportConfigDialog.tsx" export default function AccountSettingsPage() { const { config } = useConfig(); @@ -255,14 +256,10 @@ export default function AccountSettingsPage() {

Config

-
- -

Import Another Config

- -
+ -

Other

+

Other

Delete account

diff --git a/app/(config-wrapper)/settings/general/page.tsx b/app/(config-wrapper)/settings/general/page.tsx index df77d922..d56504ca 100644 --- a/app/(config-wrapper)/settings/general/page.tsx +++ b/app/(config-wrapper)/settings/general/page.tsx @@ -3,13 +3,12 @@ import config from "@/lib/config"; export default function GeneralSettingsPage() { return <>

General

-
-

App Info

- dashwise Version {config.version} -
    -
  • Github Repo
  • -
  • Github Issues
  • -
  • Support me on Ko-fi
  • +
    +

    App Info

    +
    dashwise
    Version {config.version}
    +
    ; } diff --git a/app/(config-wrapper)/settings/links/page.tsx b/app/(config-wrapper)/settings/links/page.tsx index 7d645936..31a4bd87 100644 --- a/app/(config-wrapper)/settings/links/page.tsx +++ b/app/(config-wrapper)/settings/links/page.tsx @@ -322,8 +322,6 @@ export default function LinksSettingsPage() { setDropIndex(null); }; - console.log(linkGroups) - return ( <>

    Links

    diff --git a/app/api/v1/auth/callback/route.ts b/app/api/v1/auth/callback/route.ts new file mode 100644 index 00000000..f8a73367 --- /dev/null +++ b/app/api/v1/auth/callback/route.ts @@ -0,0 +1,57 @@ +import { getServerPB } from '@/lib/pb'; +import { NextResponse } from 'next/server'; +import path from 'path'; +import { promises as fs } from 'fs'; +import config from '@/lib/config'; + +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (!code || !state) { + return NextResponse.json({ error: 'Invalid OAuth callback parameters' }, { status: 400 }); + } + + const pb = getServerPB(); + + try { + // Finish OAuth with PocketBase + const authData = await pb + .collection('users') + .authWithOAuth2Code('oidc2', code, state, `${process.env.NEXT_PUBLIC_BASE_URL}/api/v1/auth/callback`); + + const user = authData.record; + + // --- Check if userConfig exists --- + const configs = await pb.collection('userConfig').getFullList({ + filter: `associatedUserId="${user.id}"`, + }); + + if (configs.length === 0) { + // Load default config and create one + const configPath = path.join(process.cwd(), 'public', 'default-config.json'); + const configFile = await fs.readFile(configPath, 'utf-8'); + const configJson = JSON.parse(configFile); + + await pb.collection('userConfig').create({ + associatedUserId: user.id, + config: configJson, + }); + } + + // --- Store auth token as a cookie so client can move it to localStorage --- + const response = NextResponse.redirect(`${config.app_base_url}/home`); + response.cookies.set('pb_token', authData.token, { + httpOnly: false, + secure: true, + sameSite: 'lax', + path: '/', + }); + + return response; + } catch (err: any) { + console.error('OAuth error:', err.response || err); + return NextResponse.json({ error: 'OAuth login failed' }, { status: 401 }); + } +} diff --git a/app/api/v1/auth/sso/route.ts b/app/api/v1/auth/sso/route.ts new file mode 100644 index 00000000..6a691703 --- /dev/null +++ b/app/api/v1/auth/sso/route.ts @@ -0,0 +1,20 @@ +import { getServerPB } from '@/lib/pb'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const provider = new URL(request.url).searchParams.get('provider'); + + + const pb = getServerPB(); + const authMethods = (await pb.collection('users').listAuthMethods()) as any; + const selected = authMethods.oauth2.providers?.find((p: any) => p.name === provider); + if (selected) { + return NextResponse.redirect(selected.authUrl); + } + const provider_pocketid = authMethods.oauth2.providers[0]; + if (provider_pocketid) { + return NextResponse.redirect(provider_pocketid.authUrl); + } + return NextResponse.json({ error: 'Provider not available' }, { status: 400 }); + +} diff --git a/app/api/v1/config/route.ts b/app/api/v1/config/route.ts index 7c0812f5..9766d6fb 100644 --- a/app/api/v1/config/route.ts +++ b/app/api/v1/config/route.ts @@ -134,8 +134,6 @@ export async function PATCH(request: Request) { config[path] = newItem; - console.log(JSON.stringify(config)) - // 4. persist back to PocketBase await pb .collection('userConfig') @@ -153,4 +151,56 @@ export async function PATCH(request: Request) { { status: 500 } ); } +} + +export async function PUT(request: Request) { + try { + // 1) auth + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const token = authHeader.split(' ')[1]; + const pb = getServerPB(); + pb.authStore.save(token, null); + + let authModel; + try { + authModel = await pb.collection('users').authRefresh(); + } catch (error) { + if (error instanceof ClientResponseError && error.status === 401) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + throw error; + } + + // 2) body + const body = await request.json().catch(() => null) as { config?: unknown } | null; + if (!body || body.config === undefined || body.config === null || typeof body.config !== 'object') { + return NextResponse.json({ error: 'Body must be { "config": { ... } }' }, { status: 400 }); + } + + // 3) load existing record or create + let record: any | null = null; + try { + record = await pb.collection('userConfig') + .getFirstListItem(`associatedUserId="${authModel.record.id}"`); + } catch (e) { + record = null; + } + + if (record) { + await pb.collection('userConfig').update(record.id, { config: body.config }); + } else { + await pb.collection('userConfig').create({ + associatedUserId: authModel.record.id, + config: body.config, + }); + } + + return NextResponse.json({ succes: true }); + } catch (error) { + console.error("Error replacing config:", error); + return NextResponse.json({ error: 'Failed to replace config' }, { status: 500 }); + } } \ No newline at end of file diff --git a/app/api/v1/notifications/route.ts b/app/api/v1/notifications/route.ts index 24772485..e234da2c 100644 --- a/app/api/v1/notifications/route.ts +++ b/app/api/v1/notifications/route.ts @@ -26,7 +26,6 @@ export async function GET(req: NextRequest) { const topics = await pb.collection("notificationTopics").getFullList({ filter: `userId="${userId}"`, }); - console.log(topics) const topicIds = topics.map(t => t.id); if (topicIds.length === 0) { @@ -51,12 +50,6 @@ export async function GET(req: NextRequest) { sort: "-created", }); - // Debug: inspect expand structure for the first item - if (items.length > 0) { - console.log("expand shape (first item):", JSON.stringify(items[0].expand, null, 2)); - console.log("raw topicId (first item):", items[0].topicId); - } - if (count) { return NextResponse.json({ total: items.length, @@ -100,8 +93,6 @@ export async function GET(req: NextRequest) { }; }); - console.log("result", result) - return NextResponse.json({ items: result }); } catch (err: any) { console.error("Error in GET /notifications", err); diff --git a/app/api/v1/searchItems/route.ts b/app/api/v1/searchItems/route.ts index f06e50ac..fb453699 100644 --- a/app/api/v1/searchItems/route.ts +++ b/app/api/v1/searchItems/route.ts @@ -22,8 +22,6 @@ export async function GET(request: Request) { `associatedUserId="${authModel.record.id}"` ); - console.log(JSON.stringify(searchItemRecord)) - return NextResponse.json(searchItemRecord.searchItems); } catch (error) { console.error('Error fetching config:', error); diff --git a/app/api/v1/wallpapers/route.ts b/app/api/v1/wallpapers/route.ts index 37222c70..de748093 100644 --- a/app/api/v1/wallpapers/route.ts +++ b/app/api/v1/wallpapers/route.ts @@ -30,7 +30,6 @@ export async function POST(request: Request) { if ((auth as any).error) return (auth as any).error; const { pb, authModel } = auth as { pb: any; token: string; authModel: any }; const userId = authModel.record.id; - console.log("user", userId) // 2) parse form-data const formData = await request.formData(); const incomingFile = formData.get('image') as File | null; @@ -71,10 +70,19 @@ export async function POST(request: Request) { //include userId uploadForm.append('userId', userId); - // 6) create record in PB + // 6) get old wallpaper + let old_wallpaper = await pb.collection('wallpaperStore').getFirstListItem(`userId="${userId}"`); + + // 7) create record in PB const record = await pb.collection('wallpaperStore').create(uploadForm); - // 7) build the URL for your own GET endpoint + // 8) delete old wallpaper + if (old_wallpaper) { + const res = await pb.collection('wallpaperStore').delete(old_wallpaper.id); + console.log(`Delete result: ${res}`); + } + + // 9) build the URL for your own GET endpoint const getUrl = `/api/v1/wallpapers?fileName=${encodeURIComponent(fileNameField)}`; return NextResponse.json({ diff --git a/components/auth/AuthWelcomeForm.tsx b/components/auth/AuthWelcomeForm.tsx index e15c838a..da7378ef 100644 --- a/components/auth/AuthWelcomeForm.tsx +++ b/components/auth/AuthWelcomeForm.tsx @@ -1,5 +1,4 @@ -"use client" - +"use client"; import { useRouter } from "next/navigation" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -7,8 +6,7 @@ import config from "@/lib/config" import { useEffect } from "react" export default function AuthWelcomeFormComponent() { - const router = useRouter() - + const router = useRouter(); //on load: check for existing auth, validate using /api/v1/auth/validate-auth endpoint if returned success to /home useEffect(() => { const validateAuth = async () => { @@ -45,11 +43,16 @@ export default function AuthWelcomeFormComponent() { {config.enableSSO && ( - )} -