diff --git a/.env.example b/.env.example index 0e8f8d8..cf98e57 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,4 @@ POSTGRES_DB="watchlink" POSTGRES_USER="watchlink" POSTGRES_PASSWORD="watchlink" HOST_PORT="3000" +# Uploaded avatars are stored in the Docker volume `avatar-uploads`. diff --git a/.gitignore b/.gitignore index cbed833..ce91d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ docker-compose.override.yml Thumbs.db .kit/ package-registry/ +public/uploads/ diff --git a/Dockerfile b/Dockerfile index 7a36b8b..0f894c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/node_modules ./node_modules +RUN mkdir -p /app/public/uploads/avatars && chown -R nextjs:nodejs /app/public/uploads USER nextjs EXPOSE 3000 CMD ["node", "server.js"] diff --git a/README.md b/README.md index a0b5478..9896562 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ WatchLink is a self-hosted shared-watch web app with persistent user rooms, loca - Every user gets a stable personal room like `/rooms/@username`. - Friend requests and room access are modeled for friends-only, public, role-restricted, and explicit access. - Admin surface for rooms, users, roles, and permissions. +- Site-wide instance settings for registration mode, default room visibility, media providers, and avatar upload limits. +- Profile settings with display name and profile picture upload. - Shared playback state via Socket.IO. - Media URL normalization for YouTube, Twitch, and direct video URLs. - Uptime Kuma/Dockge-inspired app shell with system light/dark theme. @@ -94,10 +96,13 @@ services: PORT: 3000 ports: - "${HOST_PORT:-3000}:3000" + volumes: + - avatar-uploads:/app/public/uploads command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js" volumes: postgres-data: + avatar-uploads: ``` Start the stack: @@ -106,7 +111,7 @@ Start the stack: docker compose up --build ``` -The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` and uses a Postgres volume named `watchlink_postgres-data`. Keep the container `PORT` at `3000`; change `HOST_PORT` when another container already uses port 3000 on the host. +The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` and uses named volumes for Postgres data and uploaded avatars. Keep the container `PORT` at `3000`; change `HOST_PORT` when another container already uses port 3000 on the host. On first start, the web container runs `prisma migrate deploy` before starting Next.js. This creates the required tables in a clean Postgres volume. diff --git a/docker-compose.yml b/docker-compose.yml index a65e18e..472bb5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,10 @@ services: PORT: 3000 ports: - "${HOST_PORT:-3000}:3000" + volumes: + - avatar-uploads:/app/public/uploads command: sh -c "./node_modules/.bin/prisma migrate deploy && node server.js" volumes: postgres-data: + avatar-uploads: diff --git a/prisma/migrations/20260515193000_profile_and_app_settings/migration.sql b/prisma/migrations/20260515193000_profile_and_app_settings/migration.sql new file mode 100644 index 0000000..13a0137 --- /dev/null +++ b/prisma/migrations/20260515193000_profile_and_app_settings/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT; + +CREATE TABLE "AppSetting" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1de92a8..8704b95 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ model User { username String @unique passwordHash String displayName String? + avatarUrl String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt roles UserRole[] @@ -48,6 +49,12 @@ model User { submitted MediaSource[] } +model AppSetting { + key String @id + value String + updatedAt DateTime @updatedAt +} + model Role { id String @id @default(cuid()) name String @unique diff --git a/src/app/account/profile/page.tsx b/src/app/account/profile/page.tsx index ff6d15f..8b4cc8c 100644 --- a/src/app/account/profile/page.tsx +++ b/src/app/account/profile/page.tsx @@ -41,7 +41,7 @@ export default async function ProfilePage() {
- +
{user.displayName || user.username} @{user.username} diff --git a/src/app/account/settings/page.tsx b/src/app/account/settings/page.tsx index 7abdaeb..e7438f7 100644 --- a/src/app/account/settings/page.tsx +++ b/src/app/account/settings/page.tsx @@ -1,40 +1,59 @@ import { AppShell } from "@/components/app-shell"; +import { Avatar } from "@/components/avatar"; import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui"; +import { updateProfile } from "@/lib/profile-actions"; import { getShellContext } from "@/lib/shell"; import { requireCurrentUser } from "@/lib/session"; +import { getAppSettings } from "@/lib/settings"; import { requireInitialSetup } from "@/lib/setup"; export const dynamic = "force-dynamic"; -export default async function AccountSettingsPage() { +export default async function AccountSettingsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) { await requireInitialSetup(); const user = await requireCurrentUser(); const shell = await getShellContext(user); + const settings = await getAppSettings(); + const { error, saved } = await searchParams; return ( } + description="Account preferences, display name, avatar upload, and session options." + meta={ + <> + + + + } />
-
+ {saved ?

Profile updated.

: null} + {error === "avatar" ?

Upload a PNG, JPG, WEBP, or GIF below {settings.maxAvatarUploadMb} MB.

: null} + +
+ +
+ {user.displayName || user.username} + @{user.username} +
+
- +
- +
diff --git a/src/app/activity/page.tsx b/src/app/activity/page.tsx index 74777db..d87c13a 100644 --- a/src/app/activity/page.tsx +++ b/src/app/activity/page.tsx @@ -112,7 +112,7 @@ export default async function ActivityPage() { recentMedia.slice(0, 5).map((item) => (
- +
{item.title || item.originalUrl} {item.room.name} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d8e5de7..1128962 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10,12 +10,14 @@ import { getShellContext } from "@/lib/shell"; import { requireCurrentUser, userIsAdmin } from "@/lib/session"; import { requireInitialSetup } from "@/lib/setup"; import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui"; +import { getAppSettings, type AppSettings } from "@/lib/settings"; +import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions"; export const dynamic = "force-dynamic"; const adminTabs = ["Users", "Rooms", "Roles", "Invites", "Instance", "Security"]; -export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string }> }) { +export default async function AdminPage({ searchParams }: { searchParams: Promise<{ tab?: string; q?: string; saved?: string }> }) { await requireInitialSetup(); const user = await requireCurrentUser(); @@ -23,12 +25,12 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis redirect("/dashboard"); } - const { tab = "Users", q = "" } = await searchParams; + const { tab = "Users", q = "", saved } = await searchParams; const activeTab = adminTabs.includes(tab) ? tab : "Users"; const query = q.trim().toLowerCase(); const shell = await getShellContext(user); - const [users, rooms, roles, pendingRequests] = await Promise.all([ + const [users, rooms, roles, pendingRequests, appSettings] = await Promise.all([ prisma.user.findMany({ include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } }, orderBy: { createdAt: "asc" } @@ -41,7 +43,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis include: { _count: { select: { users: true, permissions: true } } }, orderBy: { name: "asc" } }), - prisma.friendship.count({ where: { status: "PENDING" } }) + prisma.friendship.count({ where: { status: "PENDING" } }), + getAppSettings() ]); const filteredUsers = query ? users.filter((account) => @@ -103,8 +106,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis {activeTab === "Rooms" ? : null} {activeTab === "Roles" ? : null} {activeTab === "Invites" ? : null} - {activeTab === "Instance" ? : null} - {activeTab === "Security" ? : null} + {activeTab === "Instance" ? : null} + {activeTab === "Security" ? : null} ); @@ -117,6 +120,7 @@ function UsersTable({ id: string; username: string; displayName: string | null; + avatarUrl: string | null; createdAt: Date; roles: Array<{ roleId: string; role: { name: string } }>; _count: { ownedRooms: number; roomMembers: number }; @@ -139,7 +143,7 @@ function UsersTable({
- +
{account.displayName || account.username} @{account.username} @@ -275,9 +279,45 @@ function InvitesPanel({ pendingRequests }: { pendingRequests: number }) { ); } -function InstancePanel({ userCount, roomCount }: { userCount: number; roomCount: number }) { +function InstancePanel({ userCount, roomCount, settings, saved }: { userCount: number; roomCount: number; settings: AppSettings; saved?: string }) { return (
+ + {saved ?

Instance settings saved.

: null} +
+ +