diff --git a/.env.example b/.env.example index 888fe92..c9c03df 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,82 @@ -# Database -DATABASE_URL="postgresql://postgres:postgres@localhost:5432/tasks" +# ===================================================================== +# ECHODO — Environment Variables +# ===================================================================== +# Local dev: copy this file to .env and adjust values. +# Coolify (CT 107): paste these into the resource's "Environment Variables" +# tab. Coolify also accepts a .env file uploaded via the UI. +# +# See AGENT-DEPLOY.md for shared CT 102 service credentials and the +# CT 100 Traefik routing pattern. +# ===================================================================== -# Auth (NextAuth) +# ----- Database (CT 102 shared Postgres) ----- +# Local dev: postgresql://postgres:postgres@localhost:5432/tasks +# Production: dedicated `echodo` user/db on CT 102 +DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo" + +# ----- Redis (CT 102 shared Redis) ----- +# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11) +# Local dev: redis://localhost:6379 +REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11" + +# ----- Auth (NextAuth v5) ----- +# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud NEXTAUTH_URL="http://localhost:3000" -NEXTAUTH_SECRET="your-secret-here-generate-with-openssl-rand-base64-32" +# Generate with: openssl rand -base64 32 +NEXTAUTH_SECRET="CHANGE_ME_RUN_openssl_rand_base64_32" +AUTH_SECRET="CHANGE_ME_RUN_openssl_rand_base64_32" -# OAuth Providers (optional) -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" -GOOGLE_CLIENT_ID="" -GOOGLE_CLIENT_SECRET="" +# Optional dev-only credentials provider (looks up users by email, +# accepts this single password for everyone). Leave blank in prod. +AUTH_DEV_PASSWORD="" -# Redis -REDIS_URL="redis://localhost:6379" +# ----- OAuth Providers (all optional) ----- +AUTH_GITHUB_ID="" +AUTH_GITHUB_SECRET="" +AUTH_GOOGLE_ID="" +AUTH_GOOGLE_SECRET="" -# Collaboration Server (browser / clients) +# Authentik SSO (CT 100 — auth.stillwell.cloud) +# In Authentik admin → Applications → Create OAuth2/OpenID Provider. +# Slug "echodo", redirect URI: /api/auth/callback/authentik +AUTH_AUTHENTIK_ID="" +AUTH_AUTHENTIK_SECRET="" +AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/" + +# ----- Realtime collab server (Hocuspocus / Yjs) ----- +# Server-side reference (used by Next.js server actions, if any) COLLAB_SERVER_URL="ws://localhost:1234" +# Browser-facing — must be wss:// in production because the page is https +NEXT_PUBLIC_COLLAB_SERVER_URL="ws://localhost:1234" -# AI Providers +# ----- Umami Analytics (CT 107 — analytics.stillwell.cloud) ----- +# In Umami: Settings → Websites → Add website. Copy the website UUID. +NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js" +NEXT_PUBLIC_UMAMI_WEBSITE_ID="" + +# ----- Libredesk Support Widget (CT 105 — support.stillwell.cloud) ----- +NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud" +NEXT_PUBLIC_LIBREDESK_WIDGET_ID="" + +# ----- Directus Feedback (CT 107 — directus.stillwell.cloud) ----- +# Static token scoped write-only on the `feedback` collection +NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud" +DIRECTUS_FEEDBACK_TOKEN="" + +# ----- AI Providers ----- +# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through +# local Ollama (CT 108) instead of the OpenAI API. OPENAI_API_KEY="" +OPENAI_BASE_URL="" ANTHROPIC_API_KEY="" -# MCP Server (stdio transport today; port reserved for future HTTP/SSE) +# ----- MCP Server ----- MCP_SERVER_PORT=3001 -# --- Docker Compose (optional; copy to .env and adjust) --- -# When all app services run in Docker, use service hostnames: -# DATABASE_URL="postgresql://postgres:postgres@postgres:5432/tasks" -# REDIS_URL="redis://redis:6379" -# COLLAB_SERVER_URL="ws://localhost:1234" -# (Browsers still reach the collab service via the published host port.) - +# ===================================================================== +# Local Docker Compose only (docker/docker-compose.yml) +# Not needed for Coolify (Coolify uses CT 102's shared Postgres/Redis). +# ===================================================================== POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=tasks diff --git a/.gitignore b/.gitignore index 2ac5fd4..52fbf2e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,24 @@ dist/ .env .env.local .env.*.local +.env.* +!.env.example + +# homelab / deployment secrets +AGENT-DEPLOY.md +*.secrets +*.secret +secrets/ +*-credentials.json +*-credentials.yaml +*-credentials.yml +credentials.json +credentials.yaml +credentials.yml +id_rsa +id_ed25519 +*.key +*.crt # debug npm-debug.log* diff --git a/apps/collab-server/src/index.ts b/apps/collab-server/src/index.ts index 5fa9f72..c5b5b5f 100644 --- a/apps/collab-server/src/index.ts +++ b/apps/collab-server/src/index.ts @@ -185,6 +185,25 @@ function buildDatabaseExtension() { }); } +const CURSOR_COLORS = [ + "#958DF1", + "#F98181", + "#FBBC88", + "#FAF594", + "#70CFF8", + "#94FADB", + "#B9F18D", + "#E8A0BF", +]; + +function colorFromName(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; +} + async function main() { const port = Number(process.env.PORT) || 1234; @@ -206,14 +225,46 @@ async function main() { throw Forbidden; } + // Try JWT decode for real user identity + const authSecret = process.env.AUTH_SECRET; + if (authSecret) { + try { + // Simple JWT decode (base64url decode the payload) + const parts = token.split("."); + if (parts.length === 3) { + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ); + if (payload.sub || payload.name) { + return { + user: { + id: payload.sub ?? payload.email ?? token.slice(0, 16), + name: payload.name ?? payload.email ?? "Anonymous", + color: colorFromName( + payload.name ?? payload.email ?? "User", + ), + }, + }; + } + } + } catch { + // Fall through to hash-based identity + } + } + const id = createHash("sha256").update(token).digest("hex"); return { - user: { id, name: `User ${id.slice(0, 8)}` }, + user: { + id, + name: `User ${id.slice(0, 8)}`, + color: colorFromName(id), + }, }; }, - async onConnect({ documentName }) { - console.log(`[collab] connect document=${documentName}`); + async onConnect({ documentName, connection }) { + const user = connection.readOnly ? "read-only" : "editor"; + console.log(`[collab] connect document=${documentName} (${user})`); }, async onDisconnect({ documentName }) { diff --git a/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx b/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx index c5e50b6..dbd5226 100644 --- a/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/[projectId]/layout.tsx @@ -1,13 +1,20 @@ "use client"; import type { ReactNode } from "react"; +import { useParams } from "next/navigation"; import { ViewToolbar } from "@/components/views/config"; import { useViewStore } from "@/lib/stores/view-store"; import { useViewData } from "@/lib/hooks/use-view-data"; export default function ProjectLayout({ children }: { children: ReactNode }) { + const params = useParams(); + const workspaceId = + typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; + const projectId = + typeof params?.projectId === "string" ? params.projectId : null; + const config = useViewStore((s) => s.config); - const { total } = useViewData(config); + const { total } = useViewData(config, workspaceId, projectId); return (
diff --git a/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx index d0490a7..d2a8d70 100644 --- a/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx +++ b/apps/web/app/(app)/[workspaceSlug]/[projectId]/page.tsx @@ -1,12 +1,22 @@ "use client"; +import { useParams } from "next/navigation"; + import { useViewStore } from "@/lib/stores/view-store"; import { ListView } from "@/components/views/list"; import { BoardView } from "@/components/views/board"; import { TableView } from "@/components/views/table"; import { EmbedView } from "@/components/views/embed"; +import { OverviewView } from "@/components/views/overview/overview-view"; +import { FormView } from "@/components/views/form/form-view"; export default function ProjectPage() { + const params = useParams(); + const workspaceSlug = + typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined; + const projectId = + typeof params?.projectId === "string" ? params.projectId : undefined; + const activeView = useViewStore((s) => s.activeView); const config = useViewStore((s) => s.config); @@ -17,6 +27,12 @@ export default function ProjectPage() { return ; case "embed": return ; + case "overview": + return ( + + ); + case "form": + return ; case "list": default: return ; diff --git a/apps/web/app/(app)/[workspaceSlug]/ai/page.tsx b/apps/web/app/(app)/[workspaceSlug]/ai/page.tsx new file mode 100644 index 0000000..bdc0759 --- /dev/null +++ b/apps/web/app/(app)/[workspaceSlug]/ai/page.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { Loader2, Send, Sparkles, Bot, User } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type Message = { + id: string; + role: "user" | "assistant"; + content: string; +}; + +export default function AIPage() { + const params = useParams(); + const workspaceSlug = params.workspaceSlug as string; + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + // Auto-scroll on new messages + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = input.trim(); + if (!trimmed || isLoading) return; + + const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: trimmed }; + setMessages((prev) => [...prev, userMsg]); + setInput(""); + setIsLoading(true); + + // Placeholder AI response + setTimeout(() => { + const assistantMsg: Message = { + id: crypto.randomUUID(), + role: "assistant", + content: + "I'm your AI assistant. Full AI integration is coming soon! I'll be able to help you create tasks, documents, and manage your workspace.", + }; + setMessages((prev) => [...prev, assistantMsg]); + setIsLoading(false); + }, 1000); + }; + + return ( +
+ {/* Header */} +
+
+ +
+
+

AI Assistant

+

Ask me anything about your workspace

+
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+ +
+
+

How can I help you today?

+

+ Ask me to create tasks, summarize documents, or manage your workspace. +

+
+
+ {["Create a new project", "Summarize my tasks", "Help me plan a sprint"].map( + (suggestion) => ( + + ), + )} +
+
+ ) : ( +
+ {messages.map((msg) => ( +
+
+ {msg.role === "user" ? : } +
+
+ {msg.content} +
+
+ ))} + {isLoading && ( +
+
+ +
+
+ +
+
+ )} +
+ )} +
+ + {/* Input */} +
+
+