diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 3c302c22..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - web-quality: - name: Web Verification - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Check Formatting - run: npx prettier --check . - - - name: Lint - run: npm run lint - - - name: Type Check - run: npx tsc --noEmit - - - name: Test - run: npm run test -- --run - - - name: Web Build - run: npm run build - - native-verification: - name: Native Build (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - needs: web-quality - strategy: - matrix: - os: [macos-latest, windows-latest, ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js 22 - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Build Electron App - # This builds both the renderer (Vite) and the main process, then packages it - # We don't publish here, just verify it builds - run: npm run electron:build - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed for electron-builder even if not publishing diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 3eb13143..00000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run Playwright tests - run: npx playwright test - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7710733e..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Build & Release - -on: - push: - tags: - - "v*" - workflow_dispatch: - -permissions: - contents: write - -jobs: - release: - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - - steps: - - name: Check out Git repository - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: "npm" - - - name: Install Dependencies - run: npm ci - - # Linux specific setup (if needed for electron) - # Usually github runners have libraries, but sometimes libicns is needed - - - name: Build & Release (macOS) - if: matrix.os == 'macos-latest' - run: npm run electron:build -- --mac - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build & Release (Windows) - if: matrix.os == 'windows-latest' - run: npm run electron:build -- --win - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build & Release (Linux) - if: matrix.os == 'ubuntu-latest' - run: npm run electron:build -- --linux - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Upload Artifacts (Optional, Electron Builder usually releases to GitHub Releases if GH_TOKEN is present) - # But standard actions/upload-artifact helps debug if not releasing to GitHub - - name: Upload Artifacts - uses: actions/upload-artifact@v4 - with: - name: release-artifacts-${{ matrix.os }} - path: release/* diff --git a/package-lock.json b/package-lock.json index 0610d8f8..7c1a8a5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -19,6 +20,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-visually-hidden": "^1.2.4", "@sentry/react": "^10.30.0", + "@supabase/supabase-js": "^2.88.0", "clsx": "^2.1.1", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", @@ -2350,6 +2352,71 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -3038,6 +3105,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -3583,6 +3668,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@supabase/auth-js": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.88.0.tgz", + "integrity": "sha512-r/tlKD1Sv5w5AGmxVdBK17KwVkGOHMjihqw+HeW7Qsyes5ajLeyjL0M7jXZom1+NW4yINacKqOR9gqGmWzW9eA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.88.0.tgz", + "integrity": "sha512-p934lg2x9m0bVBXKl2EAwbyIVif21FD1VGtLNGU4iuPOyB6b0bzyRAFnK95pLj48CMJk0DU+q35TDOGcFAyxwQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.88.0.tgz", + "integrity": "sha512-8DMGXWQUGM/4e8vtW95dLlNtETTVAyCAr7NyLFACDgVaaPUsDqZvS45LjBNd18fu3n6q/zZwCk4XL2yYWBHTVA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.88.0.tgz", + "integrity": "sha512-4yMVLLq6I2KSzINlBK22vGJJYzJo9FAbfKZl7ZuarvzAClq48skgLWF7dlBCC3B/9wQckKhCfPfvyT0JVz3SXg==", + "license": "MIT", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.88.0.tgz", + "integrity": "sha512-iM1CFKzTX0XIesHA/szcCqZG54BkXoSzqlVRB/O8s2u2GsXi0oUTko0ruOgDheNcWwOABKt88b0Fs4IVfDq7tg==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.88.0.tgz", + "integrity": "sha512-XcvV+0x3ybSG1WBoRH0U0cizT1pyzkXD4lLiPaMLpj+A0jahvcrcrijBT+IQpLXOa2hbNLuHkS7yqJW67r4+nQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.88.0", + "@supabase/functions-js": "2.88.0", + "@supabase/postgrest-js": "2.88.0", + "@supabase/realtime-js": "2.88.0", + "@supabase/storage-js": "2.88.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -3847,12 +4012,17 @@ "version": "24.10.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.3.tgz", "integrity": "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/phoenix": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", + "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==", + "license": "MIT" + }, "node_modules/@types/plist": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", @@ -3918,6 +4088,15 @@ "license": "MIT", "optional": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -7800,6 +7979,15 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-corefoundation": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", @@ -11857,7 +12045,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unique-filename": { @@ -11980,6 +12167,16 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -12439,7 +12636,6 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index d6041574..c85a8978 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "dependencies": { "@monaco-editor/react": "^4.7.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -65,6 +66,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-visually-hidden": "^1.2.4", "@sentry/react": "^10.30.0", + "@supabase/supabase-js": "^2.88.0", "clsx": "^2.1.1", "dexie": "^4.2.1", "dexie-react-hooks": "^4.2.0", @@ -126,4 +128,4 @@ "eslint --fix" ] } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts index 5268bd12..bc33ef28 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,13 +1,5 @@ import { defineConfig, devices } from "@playwright/test" -/** - * Read environment variables from file. - * https://github.com/motdotla/dotenv - */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - /** * See https://playwright.dev/docs/test-configuration. */ @@ -20,7 +12,7 @@ export default defineConfig({ /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 2 : undefined, // Increase CI workers to 2 for speed /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -30,9 +22,10 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", - launchOptions: { - slowMo: 1000, - }, + + /* Optimize actions - Relaxed for CI reliability */ + actionTimeout: 30000, + navigationTimeout: 45000, }, /* Configure projects for major browsers */ @@ -58,6 +51,6 @@ export default defineConfig({ command: "npm run dev", url: "http://localhost:5173", reuseExistingServer: !process.env.CI, - timeout: 120 * 1000, + timeout: 60 * 1000, // Reduced from 120s }, }) diff --git a/src/components/dashboard/CreateScapeDialog.tsx b/src/components/dashboard/CreateScapeDialog.tsx index 3dd49b33..563c584f 100644 --- a/src/components/dashboard/CreateScapeDialog.tsx +++ b/src/components/dashboard/CreateScapeDialog.tsx @@ -16,10 +16,12 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Card } from "@/components/ui/card" -import { db } from "@/lib/db" import { cn } from "@/lib/utils" import { ENVIRONMENTS } from "@/config/environments" import type { EnvironmentId } from "@/types/environment" +import { useAuth } from "@/hooks/useAuth" +import { LocalRepository } from "@/lib/repositories/LocalRepository" +import { CloudRepository } from "@/lib/repositories/CloudRepository" export function CreateScapeDialog() { const navigate = useNavigate() @@ -32,8 +34,9 @@ export function CreateScapeDialog() { const [source, setSource] = useState<"local" | "cloud">("local") const [loading, setLoading] = useState(false) - // Auth placeholder - const isAuthenticated = false + // Real Auth + const { user } = useAuth() + const isAuthenticated = !!user // Reset template when env changes useEffect(() => { @@ -57,28 +60,31 @@ export function CreateScapeDialog() { // Create Scape (Hybrid: New ones use UUIDs) const newId = crypto.randomUUID() - await db.scapes.add({ + const repo = source === "cloud" ? new CloudRepository() : new LocalRepository() + + await repo.saveScape({ id: newId, name: name.trim(), environment: selectedEnv, template: templateConfig.id, source: source, - syncStatus: "offline", + authorId: user?.id, + syncStatus: source === "cloud" ? "synced" : "offline", createdAt: new Date(), updatedAt: new Date(), - thumbnail: undefined, - dependencies: [], }) // Add default files from template if (templateConfig.files) { const filesToAdd = templateConfig.files.map((f) => ({ + id: crypto.randomUUID(), scapeId: newId, name: f.name, content: f.content, language: f.language || "plaintext", - })) - await db.files.bulkAdd(filesToAdd) + })) as (import("@/types/file").ScapeFile & { scapeId: string })[] // Cast to satisfy repo interface + + await repo.bulkCreateFiles(filesToAdd) } setOpen(false) @@ -224,15 +230,14 @@ export function CreateScapeDialog() { { if (isAuthenticated) setSource("cloud") - else alert("CodeScape Cloud Coming Soon") }} >
diff --git a/src/components/editor/CodeEditor.tsx b/src/components/editor/CodeEditor.tsx index 2959445b..58ca896c 100644 --- a/src/components/editor/CodeEditor.tsx +++ b/src/components/editor/CodeEditor.tsx @@ -123,7 +123,8 @@ export function CodeEditor({ } onMount={handleEditorDidMount} @@ -137,6 +138,7 @@ export function CodeEditor({ }} options={{ automaticLayout: true, + // Prevent cursor jumping if possible (Monaco handles this relatively well) }} />
diff --git a/src/components/editor/SaveStatus.tsx b/src/components/editor/SaveStatus.tsx new file mode 100644 index 00000000..e4453641 --- /dev/null +++ b/src/components/editor/SaveStatus.tsx @@ -0,0 +1,42 @@ +import { RotateCw, AlertCircle, Cloud, CloudOff } from "lucide-react" + +interface SaveStatusProps { + state: "idle" | "saving" | "saved" | "error" + lastSaved: Date | null + source: "local" | "cloud" +} + +export function SaveStatus({ state, lastSaved, source }: SaveStatusProps) { + if (state === "error") { + return ( +
+ + Failed to save +
+ ) + } + + if (state === "saving") { + return ( +
+ + Saving... +
+ ) + } + + if (state === "saved" || (state === "idle" && lastSaved)) { + return ( +
+ {source === "cloud" ? ( + + ) : ( + + )} + Saved {lastSaved ? "just now" : ""} +
+ ) + } + + return null +} diff --git a/src/components/editor/TerminalPane.tsx b/src/components/editor/TerminalPane.tsx index 654e07ce..000844c0 100644 --- a/src/components/editor/TerminalPane.tsx +++ b/src/components/editor/TerminalPane.tsx @@ -15,7 +15,7 @@ interface TerminalPaneProps { onClose?: () => void isCollapsed?: boolean files?: ScapeFile[] - scapeId?: string | number + scapeId?: string scapeName?: string onDeleteFile?: (path: string) => void outputLogs?: LogEntry[] diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 793c1838..6cd7678d 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,4 +1,15 @@ import { ModeToggle } from "@/components/mode-toggle" +import { useAuth } from "@/hooks/useAuth" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" interface HeaderProps { actions?: React.ReactNode @@ -22,8 +33,47 @@ export function Header({ actions, customTitle, endActions }: HeaderProps) {
{actions} + {endActions}
) } + +function AuthButtons() { + const { user, signIn, signOut, loading } = useAuth() + if (loading) return null + + if (!user) { + return ( + + ) + } + + return ( + + + + + + +
+

{user.user_metadata.full_name}

+

{user.email}

+
+
+ + + Sign Out + +
+
+ ) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 299e8e99..37282152 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,18 +1,23 @@ import { useNavigate } from "react-router-dom" -import { LayoutDashboard, Users, BookOpen, Settings } from "lucide-react" +import { Users, BookOpen, Settings, Cloud, Laptop2 } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" +import { useAuth } from "@/hooks/useAuth" interface SidebarProps extends React.HTMLAttributes { activeTab?: string onTabChange?: (tab: string) => void } -export function Sidebar({ className, activeTab = "scape", onTabChange }: SidebarProps) { +export function Sidebar({ className, activeTab = "local_scapes", onTabChange }: SidebarProps) { const navigate = useNavigate() + const { user } = useAuth() const tabs = [ - { id: "dashboard", icon: LayoutDashboard, label: "My Scapes", path: "/dashboard" }, + { id: "local_scapes", icon: Laptop2, label: "Local Scapes", path: "/dashboard" }, + ...(user + ? [{ id: "cloud_scapes", icon: Cloud, label: "Cloud Scapes", path: "/dashboard" }] + : []), { id: "learn", icon: BookOpen, label: "Learn", path: "/dashboard" }, { id: "community", icon: Users, label: "Community", path: "/dashboard" }, ] diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 00000000..b0ebea3f --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,45 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 00000000..c9bdcb7e --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,44 @@ +import { create } from "zustand" +import { supabase } from "@/lib/supabase" +import type { Session, User } from "@supabase/supabase-js" + +interface AuthState { + session: Session | null + user: User | null + loading: boolean + signIn: () => Promise + signOut: () => Promise + setSession: (session: Session | null) => void +} + +export const useAuth = create((set) => ({ + session: null, + user: null, + loading: true, + setSession: (session) => set({ session, user: session?.user ?? null, loading: false }), + signIn: async () => { + // For now, trigger Github OAuth. In future, we can be flexible. + // Redirects to current URL. + const { error } = await supabase.auth.signInWithOAuth({ + provider: "github", + options: { + redirectTo: window.location.origin + "/dashboard", + }, + }) + if (error) throw error + }, + signOut: async () => { + const { error } = await supabase.auth.signOut() + if (error) throw error + set({ session: null, user: null }) + }, +})) + +// Initialize Listener +supabase.auth.getSession().then(({ data: { session } }) => { + useAuth.getState().setSession(session) +}) + +supabase.auth.onAuthStateChange((_event, session) => { + useAuth.getState().setSession(session) +}) diff --git a/src/hooks/useFileSystem.ts b/src/hooks/useFileSystem.ts index b74fa1b5..1f119713 100644 --- a/src/hooks/useFileSystem.ts +++ b/src/hooks/useFileSystem.ts @@ -1,38 +1,64 @@ -import { useState, useEffect, useCallback, useRef } from "react" +import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { useLiveQuery } from "dexie-react-hooks" import { db } from "@/lib/db" import type { ScapeFile, FileType } from "@/types/file" +import { LocalRepository } from "@/lib/repositories/LocalRepository" +import { CloudRepository } from "@/lib/repositories/CloudRepository" +import type { IScapeRepository } from "@/lib/repositories/types" -export function useFileSystem(scapeId: string | number) { +export function useFileSystem(scapeId: string, source: "local" | "cloud" = "local") { const [files, setFiles] = useState([]) const [isInitialized, setIsInitialized] = useState(false) - // Live Query to DB - const dbFiles = useLiveQuery(() => db.files.where("scapeId").equals(scapeId).toArray(), [scapeId]) + // Select Repository + const repo = useMemo(() => { + return source === "cloud" ? new CloudRepository() : new LocalRepository() + }, [source]) + + // --- Data Loading Strategy --- + // Option A: Local - Use LiveQuery for reactivity + const localDbFiles = useLiveQuery(() => { + if (source === "local") { + return db.files.where("scapeId").equals(scapeId).toArray() + } + return Promise.resolve(undefined) + }, [scapeId, source]) + + // Option B: Cloud - Use manual fetch (LiveQuery doesn't support generic repo yet) + const [cloudFiles, setCloudFiles] = useState(null) + + useEffect(() => { + if (source === "cloud") { + repo.getFiles(scapeId).then(setCloudFiles).catch(console.error) + } + }, [repo, scapeId, source]) + + // Unify Data Sources + const rawFiles = source === "local" ? localDbFiles : cloudFiles // --- Synchronization Logic --- useEffect(() => { - if (!dbFiles) return + // Wait for data to be loaded + if (!rawFiles) return - // Map DB files to ScapeFiles - const mappedFiles: ScapeFile[] = dbFiles.map((f) => ({ + // Map DB files to ScapeFiles (ensure types) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mappedFiles: ScapeFile[] = (rawFiles as any[]).map((f) => ({ id: f.id, name: f.name, - language: f.language as unknown as FileType, + language: f.language as FileType, content: f.content, })) if (!isInitialized) { - // First Load: Trust DB + // First Load: Trust Source // eslint-disable-next-line react-hooks/set-state-in-effect setFiles(mappedFiles) setIsInitialized(true) } else { - // Subsequent Updates: Smart Sync - // We only want to accept changes from the DB if the STRUCTURE changed (added/removed/renamed files) - // We DO NOT want to accept content changes from the DB if we are currently editing (prevent loops). - + // Subsequent Updates: Smart Merge + // Only update if structure changed or if we are not editing locally const currentStructure = files .map((f) => `${f.id}:${f.name}`) .sort() @@ -43,48 +69,55 @@ export function useFileSystem(scapeId: string | number) { .join("|") if (currentStructure !== newStructure) { - // Merge Strategy: - // 1. Take structure from DB (Source of Truth for existence) - // 2. Preserve local content for files that exist in both (Source of Truth for edits) - const merged = mappedFiles.map((dbFile) => { - const local = files.find((f) => f.id === dbFile.id) + const merged = mappedFiles.map((remoteFile) => { + const local = files.find((f) => f.id === remoteFile.id) if (local) { - // Keep local content - return { ...dbFile, content: local.content } + // Keep local content to prevent overwriting unsaved work if re-fetch happens + // However, for Cloud, we probably want to trust remote if it updated? + // For now, stick to "Local is Editor State". + return { ...remoteFile, content: local.content } } - // New file from DB -> Use DB content - return dbFile + return remoteFile }) - setFiles(merged) } } - }, [dbFiles, isInitialized, files]) // Depend on 'files' for the merge logic + }, [rawFiles, isInitialized, files]) // --- Auto-Save Logic --- - // We used to do this in the Editor, but now the Hook owns it. - // Actually, simpler approach: Trigger save in `updateFile` but debounced? - // Or keep a separate effect that watches `files` and saves changed ones? - // Given we want "One Way Data Flow", explicit saves are better than watching state. - // But purely explicit (in updateFile) floods the DB. - // Let's implement a ref-based debounce for saving content. + const saveTimeoutRef = useRef>({}) + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle") + const [lastSaved, setLastSaved] = useState(null) - const saveTimeoutRef = useRef>({}) - - const saveContentToDb = useCallback( - (fileId: number, content: string | Blob | ArrayBuffer | Uint8Array) => { + const saveContentToRepo = useCallback( + (fileId: string, content: string | Blob | ArrayBuffer | Uint8Array) => { if (saveTimeoutRef.current[fileId]) { clearTimeout(saveTimeoutRef.current[fileId]) } + setSaveState("saving") + saveTimeoutRef.current[fileId] = setTimeout(async () => { - await db.files.update(fileId, { content }) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await db.scapes.update(scapeId as any, { updatedAt: new Date() }) // Cast scapeId for update - delete saveTimeoutRef.current[fileId] - }, 500) // 500ms Debounce for DB writes + try { + await repo.updateFileContent(fileId, content) + + // Update Timestamp on Scape (if supported) + await repo.updateScape(scapeId, { updatedAt: new Date() }) + + delete saveTimeoutRef.current[fileId] + + // Only set to saved if no other saving operations are pending + if (Object.keys(saveTimeoutRef.current).length === 0) { + setSaveState("saved") + setLastSaved(new Date()) + } + } catch (e) { + console.error("Failed to auto-save:", e) + setSaveState("error") + } + }, 1000) // 1s Debounce }, - [scapeId] + [repo, scapeId] ) // --- Actions --- @@ -95,16 +128,34 @@ export function useFileSystem(scapeId: string | number) { language: FileType, content: string | Blob | ArrayBuffer | Uint8Array = "" ) => { - // Optimistic: We can't really do optimistic create easily because we need the ID from DB. - // So we await DB. The `useEffect` above will handle adding it to state when it detects structure change. - await db.files.add({ + const newFile: ScapeFile & { scapeId: string } = { + id: crypto.randomUUID(), scapeId, name, language, content, - }) + } + + // Optimistic Update (Optional, but good for UI) + setFiles((prev) => [...prev, newFile]) + + try { + await repo.createFile(newFile) + // If Cloud, we might need to re-fetch to confirm? + // Or rely on the fact that we pushed it. + // For Local, LiveQuery will catch it. + if (source === "cloud") { + // Manually update cloudFiles state to prevent flicker + setCloudFiles((prev) => (prev ? [...prev, newFile] : [newFile])) + } + } catch (e) { + console.error("Failed to create file:", e) + // Rollback? + setFiles((prev) => prev.filter((f) => f.id !== newFile.id)) + alert("Failed to create file") + } }, - [scapeId] + [repo, scapeId, source] ) const updateFile = useCallback( @@ -112,54 +163,188 @@ export function useFileSystem(scapeId: string | number) { setFiles((prev) => prev.map((f) => { if (f.name === name) { - if (f.id) saveContentToDb(f.id, content) + if (f.id) saveContentToRepo(f.id, content) return { ...f, content } } return f }) ) }, - [saveContentToDb] + [saveContentToRepo] ) - // Renaming is structural, so await DB const renameFile = useCallback( async (oldPath: string, newPath: string) => { - // 1. Find file const file = files.find((f) => f.name === oldPath) - // Folder renaming? Handled by caller usually, but if file: if (file && file.id) { - await db.files.update(file.id, { name: newPath }) + // Optimistic + setFiles((prev) => prev.map((f) => (f.id === file.id ? { ...f, name: newPath } : f))) + + try { + await repo.updateFileName(file.id, newPath) + if (source === "cloud") { + setCloudFiles( + (prev) => prev?.map((f) => (f.id === file.id ? { ...f, name: newPath } : f)) || null + ) + } + } catch (e) { + console.error("Rename failed", e) + // Rollback logic would go here + } } - // Deep rename (folders) is complex, usually handled by checking startsWith. - // For now assuming caller handles atomic renames or this hook exposes specific methods. - // Let's implement a bulk rename helper if needed, or just let 'updateFile' handle content. - // Actually, ScapeEditor handles recursive renames. - // Instead of duplicating that logic here, let's expose a generic 'updateAttributes' or specific 'rename'. - - // For now, let's stick to simple file ops. Complex folder moves might essentially be direct DB calls - // that invoke the 'Structure Change' sync. - // So exposing direct DB access or generic helper is fine. }, - [files] + [files, repo, source] ) const deleteFile = useCallback( async (path: string) => { - // Recursive delete const toDelete = files.filter((f) => f.name === path || f.name.startsWith(path + "/")) - const ids = toDelete.map((f) => f.id).filter((id): id is number => id !== undefined) + const ids = toDelete.map((f) => f.id).filter((id): id is string => id !== undefined) + if (ids.length > 0) { - await db.files.bulkDelete(ids) + // Optimistic + setFiles((prev) => prev.filter((f) => !ids.includes(f.id!))) + + try { + await repo.bulkDeleteFiles(ids) + if (source === "cloud") { + setCloudFiles((prev) => prev?.filter((f) => !ids.includes(f.id!)) || null) + } + } catch (e) { + console.error("Delete failed", e) + } } }, - [files] + [files, repo, source] ) - // Custom bulk update (for folders move) - const bulkRename = useCallback(async (updates: { id: number; name: string }[]) => { - await Promise.all(updates.map((u) => db.files.update(u.id, { name: u.name }))) - }, []) + const bulkRename = useCallback( + async (updates: { id: string; name: string }[]) => { + // Optimistic + setFiles((prev) => + prev.map((f) => { + const update = updates.find((u) => u.id === f.id) + return update ? { ...f, name: update.name } : f + }) + ) + + try { + await repo.bulkUpdateFiles(updates.map((u) => ({ id: u.id, changes: { name: u.name } }))) + if (source === "cloud") { + setCloudFiles( + (prev) => + prev?.map((f) => { + const update = updates.find((u) => u.id === f.id) + return update ? { ...f, name: update.name } : f + }) || null + ) + } + } catch (e) { + console.error("Bulk rename failed", e) + } + }, + [repo, source] + ) + + const updateScape = useCallback( + async (updates: Partial) => { + try { + await repo.updateScape(scapeId, updates) + } catch (e) { + console.error("Failed to update scape:", e) + } + }, + [repo, scapeId] + ) + + // --- Real-Time Subscription (Cloud) --- + useEffect(() => { + if (source === "cloud" && repo.subscribeToFiles) { + const unsubscribe = repo.subscribeToFiles(scapeId, (event, payload) => { + // Client-Side Filter: Ensure event is for THIS scape + // Raw Payload: { schema: 'public', table: 'files', commit_timestamp: '...', eventType: '...', new: {}, old: {} } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const p = payload as any + const record = p.new || p.old + // Defensive check for scape_id. For DELETE, scape_id might be missing if REPLICA IDENTITY not full, so we skip check for DELETE. + if (event !== "DELETE") { + if (record && record.scape_id && record.scape_id !== scapeId) return + } + + console.log("Realtime Event:", event, payload) + + if (event === "INSERT") { + const newRecord = p.new + if (!newRecord || newRecord.scape_id !== scapeId) return + + const newFile: ScapeFile = { + id: newRecord.id, + name: newRecord.name, + language: newRecord.language, + content: newRecord.content || "", + } + setFiles((prev) => { + if (prev.some((f) => f.id === newFile.id)) return prev + return [...prev, newFile] + }) + setCloudFiles((prev) => { + if (!prev) return [newFile] + if (prev.some((f) => f.id === newFile.id)) return prev + return [...prev, newFile] + }) + } + + if (event === "DELETE") { + // DELETE payload usually has 'old' property with the ID + // Or if we passed full payload from repo, it might be nested + // We cast to any to safely access potentially missing properties + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const raw = payload as any + const id = raw.id || raw.old?.id || (raw.payload && raw.payload.old && raw.payload.old.id) + + if (id) { + console.log("Processing Realtime Delete for ID:", id) + setFiles((prev) => prev.filter((f) => f.id !== id)) + setCloudFiles((prev) => prev?.filter((f) => f.id !== id) || null) + } else { + console.warn("Realtime DELETE received but no ID found:", payload) + } + } + + if (event === "UPDATE") { + const newRecord = p.new + const id = newRecord.id + + const isMeSaving = !!saveTimeoutRef.current[id] + + if (!isMeSaving) { + setFiles((prev) => + prev.map((f) => { + if (f.id === id) { + return { + ...f, + name: newRecord.name, + content: newRecord.content, + language: newRecord.language, + } + } + return f + }) + ) + setCloudFiles( + (prev) => + prev?.map((f) => + f.id === id ? { ...f, name: newRecord.name, content: newRecord.content } : f + ) || null + ) + } + } + }) + return () => { + unsubscribe() + } + } + }, [repo, scapeId, source]) return { files, @@ -169,5 +354,8 @@ export function useFileSystem(scapeId: string | number) { renameFile, deleteFile, bulkRename, + updateScape, + saveState, + lastSaved, } } diff --git a/src/hooks/useScapeLoading.ts b/src/hooks/useScapeLoading.ts new file mode 100644 index 00000000..a8538862 --- /dev/null +++ b/src/hooks/useScapeLoading.ts @@ -0,0 +1,200 @@ +import { useState, useEffect } from "react" +import { useLiveQuery } from "dexie-react-hooks" +import { db, type Scape } from "@/lib/db" +import { supabase } from "@/lib/supabase" + +export type ScapeSource = "local" | "cloud" + +export function useScapeLoading(id: string) { + const [cloudScape, setCloudScape] = useState(null) + const [cloudError, setCloudError] = useState(null) + + // 1. Try Local First (Live Logic) + const localScape = useLiveQuery(() => db.scapes.get(id), [id]) + + // 2. If not found locally, try Cloud + useEffect(() => { + // If localScape is undefined (still loading) or found (exists), we don't need to fetch cloud yet? + // Wait, useLiveQuery returns undefined while loading, and undefined if not found. + // Dexie: if not found, distinct from loading? `useLiveQuery` initial return is undefined. + // Actually `useLiveQuery` executes immediately if possible? No. + // To distinguish "not found" vs "loading", we need a bit more care. + // But for MVP: If localScape happens to be undefined, let's assume "not found locally" and trigger cloud fetch? + // Problem: Race condition. + + // Better approach: + // Always trigger cloud fetch if ID looks like UUID? + // Or just run both in parallel? + // If we have local, use it. If not, wait for cloud. + + if (localScape !== undefined) return // We have a result (either object or null/undefined if finished?) + // Actually Dexie hooks return undefined initially then the value. If value is undefined, it means record not found. + + // Let's explicitly check: + let isMounted = true + + const checkCloud = async () => { + // Small delay to let Dexie resolve? + // Or just check Cloud if we suspect it's there. + // If we blindly fetch Cloud every time, it's valid but wasteful for local scapes. + + // Heuristic: If we don't have localScape after a tick, or if we just want to support direct link. + const { data, error } = await supabase.from("scapes").select("*").eq("id", id).single() + + if (!isMounted) return + + if (error) { + if (error.code !== "PGRST116") { + // 116 is Row not found + setCloudError(error) + } + return + } + + if (data) { + // Found in cloud + setCloudScape({ + id: data.id, + name: data.name, + environment: data.environment as Scape["environment"], + template: data.template, + source: "cloud", + authorId: data.author_id, + syncStatus: "synced", + createdAt: new Date(data.created_at), + updatedAt: new Date(data.updated_at), + thumbnail: data.thumbnail, + dependencies: data.dependencies || [], + }) + } + } + + // Only fetch cloud if we have no local scape roughly "settled" + // Since we can't easily tell "Dexie finished loading and found nothing", + // We might just fire the Cloud request if `localScape` is falsy. + // But this fires even during initial load. + + // Let's just fire it. Supabase is fast. + checkCloud() + + // --- Real-Time Subscription --- + const channel = supabase + .channel(`scape-meta:${id}`) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "scapes", + // Client-side filtering is more robust against RLS/filter interactions + }, + (payload) => { + const newData = payload.new + // Client-Side Filter + if (newData && newData.id === id) { + console.log("Realtime Scape Update:", newData) + setCloudScape((prev) => { + // If we have no previous state, we can't merge reliably unless payload is full. + // With REPLICA IDENTITY FULL, newData is full. + // Safe Merge: prefer newData, fallback to prev + + const base = prev || ({} as Scape) + + return { + ...base, + // If newData has property, use it. If not, keep prev. + // Note: newData from Supabase with REPLICA IDENTITY FULL should have all columns. + // But we safeguard against partials. + name: newData.name !== undefined ? newData.name : base.name, + dependencies: + newData.dependencies !== undefined + ? newData.dependencies + : base.dependencies || [], + updatedAt: newData.updated_at ? new Date(newData.updated_at) : base.updatedAt, + thumbnail: newData.thumbnail !== undefined ? newData.thumbnail : base.thumbnail, + + // Ensure key fields are preserved if not in payload (unlikely with Full Identity) + id: id, + source: "cloud", + environment: newData.environment + ? (newData.environment as Scape["environment"]) + : base.environment, + template: newData.template || base.template, + authorId: base.authorId, // author_id is not expected to change on update + syncStatus: "synced", + createdAt: base.createdAt || new Date(), // createdAt is not expected to change on update + } as Scape + }) + } + } + ) + .on("broadcast", { event: "scape_update" }, (payload) => { + const newData = payload.payload // Payload structure: { payload: { ... } } ? Checking docs. + // Supabase sends payload as the argument. + // But payload includes event, type, payload... + // "payload" property contains the data sent. + const data = newData as Partial + + if (data) { + console.log("Broadcast Scape Update:", data) + setCloudScape((prev) => { + const base = prev || ({} as Scape) + return { + ...base, + ...data, + // Preserve ID and critical fields if not in payload + id: id, + source: "cloud", + } as Scape + }) + } + }) + .subscribe() + + return () => { + isMounted = false + supabase.removeChannel(channel) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + + // useCallback to stabilize emitUpdate for consumers + + const emitUpdate = async (updates: Partial) => { + if (!id) return + await supabase.channel(`scape-meta:${id}`).send({ + type: "broadcast", + event: "scape_update", + payload: updates, + }) + } + + // Prioritize Local, then Cloud + const scape = localScape ?? cloudScape + + // Loading state is rough here. + // If both are null/undefined, we are loading. + const isLoading = localScape === undefined && cloudScape === null && !cloudError + + // If localScape is explicitly undefined (entry not found) AND cloudScape is null (not found) AND not loadingCloud + // Then strictly 404. + // But localScape is undefined when loading AND when not found? + // Dexie docs: "The result of the promise returned by querier. If the promise rejects, the error is returned." + // "If the function throws, undefined is returned." + // Wait, if record doesn't exist, get() returns undefined. + // So `localScape` is `undefined` can mean "loading" OR "not found". This is annoying. + + // Ideally we use `db.scapes.get(id).then(...)` in a `useEffect` for precise control if we mix sources. + // But `useLiveQuery` is nice for updates. + + // Let's assume if `localScape` is undefined, we *might* be loading or it's missing. + // The `cloudScape` will fill in if found. + + return { + scape, + source: (scape?.source === "cloud" ? "cloud" : "local") as ScapeSource, + isLoading, + error: cloudError, + emitUpdate, + } +} diff --git a/src/hooks/useScapes.ts b/src/hooks/useScapes.ts new file mode 100644 index 00000000..f5f1d1b8 --- /dev/null +++ b/src/hooks/useScapes.ts @@ -0,0 +1,75 @@ +import { useState, useEffect, useMemo } from "react" +import { useLiveQuery } from "dexie-react-hooks" +import { type Scape } from "@/lib/db" +import { LocalRepository } from "@/lib/repositories/LocalRepository" +import { CloudRepository } from "@/lib/repositories/CloudRepository" +import { useAuth } from "@/hooks/useAuth" + +const localRepo = new LocalRepository() +const cloudRepo = new CloudRepository() + +export function useScapes() { + const { user, loading: authLoading } = useAuth() + const [cloudScapes, setCloudScapes] = useState([]) + const [loadingCloud, setLoadingCloud] = useState(false) + + // 1. Local Scapes (Live) + const localScapes = useLiveQuery(() => localRepo.listScapes(), []) + + // 2. Cloud Scapes (Effect) + useEffect(() => { + if (!user) { + setCloudScapes([]) + return + } + + let isMounted = true + const fetchCloud = async () => { + setLoadingCloud(true) + try { + const scapes = await cloudRepo.listScapes(user.id) + if (isMounted) setCloudScapes(scapes) + } catch (e) { + console.error("Failed to fetch cloud scapes", e) + } finally { + if (isMounted) setLoadingCloud(false) + } + } + + fetchCloud() + return () => { + isMounted = false + } + }, [user]) + + // 3. Merge & Sort + const combinedScapes = useMemo(() => { + const local = localScapes || [] + const cloud = cloudScapes || [] + + return [...local, ...cloud].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ) + }, [localScapes, cloudScapes]) + + const deleteScape = async (scape: Scape) => { + try { + if (scape.source === "cloud") { + await cloudRepo.deleteScape(scape.id) + // Optimistic update + setCloudScapes((prev) => prev.filter((s) => s.id !== scape.id)) + } else { + await localRepo.deleteScape(scape.id) + } + } catch (e) { + console.error("Failed to delete scape", e) + throw e + } + } + + return { + scapes: combinedScapes, + loading: authLoading || !localScapes || loadingCloud, + deleteScape, + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 32b7cc94..40c5d99c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -2,108 +2,41 @@ import Dexie, { type EntityTable } from "dexie" import type { EnvironmentId } from "@/types/environment" export interface Scape { - id: string | number + id: string // UUID name: string environment: EnvironmentId - template: string // The ID of the template used (e.g. 'blank', 'threejs') + template: string source: "local" | "cloud" syncStatus?: "synced" | "dirty" | "offline" authorId?: string cloudId?: string - thumbnail?: string // Base64 data URL + thumbnail?: string // Base64 data URL (or URL in future) createdAt: Date updatedAt: Date - dependencies?: string[] // Installed Package Names + dependencies?: string[] } export interface File { - id: number - scapeId: string | number + id: string // UUID + scapeId: string // UUID name: string content: string | Blob | ArrayBuffer | Uint8Array language: string } -const db = new Dexie("CodeScapeDB") as Dexie & { +const db = new Dexie("CodeScapeNext") as Dexie & { scapes: EntityTable files: EntityTable } -// Version 2: Added source, syncStatus, authorId, cloudId -db.version(2) - .stores({ - scapes: "++id, name, type, source, createdAt, updatedAt", - files: "++id, scapeId, name, [scapeId+name]", - }) - .upgrade((tx) => { - return tx - .table("scapes") - .toCollection() - .modify((scape) => { - scape.source = "local" - scape.syncStatus = "offline" - }) - }) - -// Version 3: Added thumbnail -db.version(3).stores({ - scapes: "++id, name, type, source, createdAt, updatedAt", - files: "++id, scapeId, name, [scapeId+name]", -}) - -// Version 4: Environments Architecture -db.version(4) - .stores({ - scapes: "++id, name, environment, source, createdAt, updatedAt", // Replaced 'type' with 'environment' - files: "++id, scapeId, name, [scapeId+name]", - }) - .upgrade((tx) => { - return ( - tx - .table("scapes") - .toCollection() - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .modify((scape: any) => { - // Default to Web environment for existing projects - scape.environment = "web" - // Migrate old 'type' to 'template' - if (scape.type) { - scape.template = scape.type - delete scape.type - } else { - scape.template = "blank" - } - }) - ) - }) - -// Version 5: Package Manager -db.version(5) - .stores({ - scapes: "++id, name, environment, source, createdAt, updatedAt, *dependencies", // Index dependencies for searching - files: "++id, scapeId, name, [scapeId+name]", - }) - .upgrade((tx) => { - return tx - .table("scapes") - .toCollection() - .modify((scape) => { - if (!scape.dependencies) { - scape.dependencies = [] - } - }) - }) - -// Version 6: Hybrid UUID Migration -// We KEEP '++' to avoid "UpgradeError: Not yet support for changing primary key". -// IndexedDB allows string keys in auto-increment tables if provided manually. -db.version(6).stores({ - scapes: "++id, name, environment, source, createdAt, updatedAt, *dependencies", - files: "++id, scapeId, name, [scapeId+name]", +// Version 1: Clean Slate +db.version(1).stores({ + scapes: "id, name, environment, source, createdAt, updatedAt, *dependencies", // 'id' (no ++) means manual UUID + files: "id, scapeId, name, [scapeId+name]", // 'id' (no ++) means manual UUID }) // Helper to delete a scape and its files transactionally -export async function deleteScape(id: string | number) { +export async function deleteScape(id: string) { await db.transaction("rw", db.scapes, db.files, async () => { await db.files.where("scapeId").equals(id).delete() await db.scapes.delete(id) diff --git a/src/lib/repositories/CloudRepository.ts b/src/lib/repositories/CloudRepository.ts new file mode 100644 index 00000000..a408fb3d --- /dev/null +++ b/src/lib/repositories/CloudRepository.ts @@ -0,0 +1,225 @@ +import { supabase } from "@/lib/supabase" +import type { Scape } from "@/lib/db" +import type { ScapeFile, FileType } from "@/types/file" +import type { IScapeRepository } from "./types" + +export class CloudRepository implements IScapeRepository { + async getScape(id: string): Promise { + const { data, error } = await supabase.from("scapes").select("*").eq("id", id).single() + + if (error) { + if (error.code === "PGRST116") return undefined // Not found + throw error + } + + // Map snake_case DB to camelCase Application + return { + id: data.id, + name: data.name, + environment: data.environment as Scape["environment"], + template: data.template, + source: "cloud", // Enforce cloud source + authorId: data.author_id, + syncStatus: "synced", // Fetched from cloud = synced + createdAt: new Date(data.created_at), + updatedAt: new Date(data.updated_at), + thumbnail: data.thumbnail, + dependencies: data.dependencies || [], + } + } + + async listScapes(userId?: string): Promise { + if (!userId) return [] + + const { data, error } = await supabase + .from("scapes") + .select("*") + .eq("author_id", userId) + .order("updated_at", { ascending: false }) + + if (error) throw error + + return data.map((d) => ({ + id: d.id, + name: d.name, + environment: d.environment as Scape["environment"], + template: d.template, + source: "cloud", + authorId: d.author_id, + syncStatus: "synced", + createdAt: new Date(d.created_at), + updatedAt: new Date(d.updated_at), + thumbnail: d.thumbnail, + dependencies: d.dependencies || [], + })) + } + + async saveScape(scape: Scape): Promise { + const { error } = await supabase.from("scapes").upsert({ + id: scape.id, + name: scape.name, + environment: scape.environment, + template: scape.template, + source: "cloud", + author_id: scape.authorId, + updated_at: new Date().toISOString(), + thumbnail: scape.thumbnail, + dependencies: scape.dependencies, + }) + + if (error) throw error + } + + async updateScape(id: string, updates: Partial): Promise { + // Map updates to snake_case + const dbUpdates: Record = {} + if (updates.name) dbUpdates.name = updates.name + if (updates.thumbnail) dbUpdates.thumbnail = updates.thumbnail + if (updates.dependencies) dbUpdates.dependencies = updates.dependencies + if (updates.updatedAt) dbUpdates.updated_at = updates.updatedAt.toISOString() + else dbUpdates.updated_at = new Date().toISOString() + + const { error } = await supabase.from("scapes").update(dbUpdates).eq("id", id) + if (error) throw error + } + + async getFiles(scapeId: string): Promise { + const { data, error } = await supabase.from("files").select("*").eq("scape_id", scapeId) + if (error) throw error + + return data.map((f) => ({ + id: f.id, + name: f.name, + language: f.language as FileType, + content: f.content, + })) + } + + async createFile(file: ScapeFile & { scapeId: string }): Promise { + if (!file.id) throw new Error("File ID is required") + + // Ensure content is string + let contentStr = "" + if (typeof file.content === "string") contentStr = file.content + else { + // TODO: Handle binary content (upload to storage?) + console.warn( + "CloudRepository: Binary content not yet fully supported, saving as empty string." + ) + contentStr = "" + } + + const { error } = await supabase.from("files").insert({ + id: file.id, + scape_id: file.scapeId, + name: file.name, + language: file.language, + content: contentStr, + }) + + if (error) throw error + } + + async bulkCreateFiles(files: (ScapeFile & { scapeId: string })[]): Promise { + if (files.length === 0) return + + const rows = files.map((f) => ({ + id: f.id, + scape_id: f.scapeId, + name: f.name, + language: f.language, + content: typeof f.content === "string" ? f.content : "", // TODO binary + })) + + const { error } = await supabase.from("files").insert(rows) + if (error) throw error + } + + async updateFileContent( + id: string, + content: string | Blob | ArrayBuffer | Uint8Array + ): Promise { + let contentStr = "" + if (typeof content === "string") contentStr = content + else { + // TODO: Binary support + console.warn("CloudRepository: Binary content update ignored.") + return + } + + const { error } = await supabase + .from("files") + .update({ content: contentStr, updated_at: new Date().toISOString() }) + .eq("id", id) + + if (error) throw error + } + + async updateFileName(id: string, name: string): Promise { + const { error } = await supabase + .from("files") + .update({ name, updated_at: new Date().toISOString() }) + .eq("id", id) + + if (error) throw error + } + + async deleteFile(id: string): Promise { + const { error } = await supabase.from("files").delete().eq("id", id) + if (error) throw error + } + + async deleteScape(id: string): Promise { + const { error } = await supabase.from("scapes").delete().eq("id", id) + if (error) throw error + } + + async bulkDeleteFiles(ids: string[]): Promise { + if (ids.length === 0) return + const { error } = await supabase.from("files").delete().in("id", ids) + if (error) throw error + } + + async bulkUpdateFiles(updates: { id: string; changes: Partial }[]): Promise { + // Supabase doesn't have a single bulk update endpoint for different values per row easily without RCP. + // Parallel updates for now. + await Promise.all( + updates.map((u) => { + const dbChanges: Record = {} // Fixed 'any' + if (u.changes.name) dbChanges.name = u.changes.name + if (u.changes.content && typeof u.changes.content === "string") + dbChanges.content = u.changes.content + + return supabase.from("files").update(dbChanges).eq("id", u.id) + }) + ) + } + + subscribeToFiles( + scapeId: string, + callback: (event: "INSERT" | "UPDATE" | "DELETE", payload: unknown) => void // Fixed 'any' + ): () => void { + const channel = supabase + .channel(`scape-files:${scapeId}`) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "files", + // NOTE: Server-side filtering for DELETE events requires REPLICA IDENTITY FULL and can be flaky. + // We remove the filter here and filter client-side in the callback/hook to be safe. + // RLS ensures we only see our own files anyway. + }, + (payload) => { + // Pass full payload so receiver can check new/old explicitly + callback(payload.eventType as "INSERT" | "UPDATE" | "DELETE", payload) // Cast eventType + } + ) + .subscribe() + + return () => { + supabase.removeChannel(channel) + } + } +} diff --git a/src/lib/repositories/LocalRepository.ts b/src/lib/repositories/LocalRepository.ts new file mode 100644 index 00000000..29b6fc88 --- /dev/null +++ b/src/lib/repositories/LocalRepository.ts @@ -0,0 +1,94 @@ +import { db, type Scape } from "@/lib/db" +import type { ScapeFile, FileType } from "@/types/file" +import type { IScapeRepository } from "./types" + +export class LocalRepository implements IScapeRepository { + async getScape(id: string): Promise { + return db.scapes.get(id) + } + + async listScapes(): Promise { + return db.scapes.orderBy("createdAt").reverse().toArray() + } + + async saveScape(scape: Scape): Promise { + await db.scapes.put(scape) + } + + async updateScape(id: string, updates: Partial): Promise { + await db.scapes.update(id, updates) + } + + async deleteScape(id: string): Promise { + // Transactional delete + await db.transaction("rw", db.scapes, db.files, async () => { + await db.files.where("scapeId").equals(id).delete() + await db.scapes.delete(id) + }) + } + + async getFiles(scapeId: string): Promise { + const files = await db.files.where("scapeId").equals(scapeId).toArray() + return files.map((f) => ({ + id: f.id, // Strictly string now + name: f.name, + language: f.language as FileType, + content: f.content, + // scapeId is internal to DB, but ScapeFile doesn't strictly need it if context is known + // But typically ScapeFile interface might have it. Let's check type definition if needed. + })) + } + + async createFile(file: ScapeFile & { scapeId: string }): Promise { + if (!file.id) throw new Error("File ID is required") + await db.files.add({ + id: file.id, + scapeId: file.scapeId, + name: file.name, + language: file.language, + content: file.content, + }) + } + + async bulkCreateFiles(files: (ScapeFile & { scapeId: string })[]): Promise { + await db.files.bulkAdd( + files.map((f) => ({ + id: f.id, + scapeId: f.scapeId, + name: f.name, + language: f.language, + content: f.content, + })) + ) + } + + async updateFileContent( + id: string, + content: string | Blob | ArrayBuffer | Uint8Array + ): Promise { + await db.files.update(id, { content }) + } + + async updateFileName(id: string, name: string): Promise { + await db.files.update(id, { name }) + } + + async deleteFile(id: string): Promise { + await db.files.delete(id) + } + + async bulkDeleteFiles(ids: string[]): Promise { + await db.files.bulkDelete(ids) + } + + async bulkUpdateFiles(updates: { id: string; changes: Partial }[]): Promise { + // Dexie doesn't have a bulkUpdate in the same way, so Promise.all is fine + await Promise.all( + updates.map((u) => { + // Map ScapeFile keys to DB keys if they differ (they partially do) + // ScapeFile has {id, name, ...} which matches. + return db.files.update(u.id, u.changes) + }) + ) + } +} diff --git a/src/lib/repositories/types.ts b/src/lib/repositories/types.ts new file mode 100644 index 00000000..a595b12c --- /dev/null +++ b/src/lib/repositories/types.ts @@ -0,0 +1,29 @@ +import type { ScapeFile } from "@/types/file" +import type { Scape } from "@/lib/db" // Re-using DB type for now, might decouple later + +export interface IScapeRepository { + // Scape Operations + getScape(id: string): Promise + listScapes(userId?: string): Promise + saveScape(scape: Scape): Promise + updateScape(id: string, updates: Partial): Promise + deleteScape(id: string): Promise + + // File Operations + getFiles(scapeId: string): Promise + createFile(file: ScapeFile & { scapeId: string }): Promise + bulkCreateFiles(files: (ScapeFile & { scapeId: string })[]): Promise + updateFileContent(id: string, content: string | Blob | ArrayBuffer | Uint8Array): Promise + updateFileName(id: string, name: string): Promise + deleteFile(id: string): Promise + + // Bulk Operations (for complex moves/deletes) + bulkDeleteFiles(ids: string[]): Promise + bulkUpdateFiles(updates: { id: string; changes: Partial }[]): Promise + + // Realtime + subscribeToFiles?( + scapeId: string, + callback: (event: "INSERT" | "UPDATE" | "DELETE", payload: unknown) => void + ): () => void +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 00000000..78e090d0 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,10 @@ +import { createClient } from "@supabase/supabase-js" + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY + +if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Missing Supabase Environmental Variables") +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 09f51cbe..0dcc4ebb 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,6 +1,5 @@ import { useState } from "react" import { useNavigate } from "react-router-dom" -import { useLiveQuery } from "dexie-react-hooks" import { Search, Globe, @@ -32,23 +31,30 @@ import { import { CreateScapeDialog } from "@/components/dashboard/CreateScapeDialog" import { Header } from "@/components/layout/Header" import { Sidebar } from "@/components/layout/Sidebar" -import { db, deleteScape, type Scape } from "@/lib/db" +import { type Scape } from "@/lib/db" +import { useScapes } from "@/hooks/useScapes" export default function Dashboard() { const navigate = useNavigate() - const [activeTab, setActiveTab] = useState("dashboard") + const [activeTab, setActiveTab] = useState("local_scapes") const [searchQuery, setSearchQuery] = useState("") // Delete Modal State const [scapeToDelete, setScapeToDelete] = useState(null) const [deleteConfirmation, setDeleteConfirmation] = useState("") - // Real data from Dexie - const myScapes = useLiveQuery(() => db.scapes.orderBy("createdAt").reverse().toArray()) + // Real data from Hooks + const { scapes: myScapes, deleteScape } = useScapes() - const filteredScapes = myScapes?.filter((scape) => - scape.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) + const filteredScapes = myScapes?.filter((scape) => { + const matchesSearch = scape.name.toLowerCase().includes(searchQuery.toLowerCase()) + if (!matchesSearch) return false + + // Tab Filtering + if (activeTab === "local_scapes") return scape.source === "local" + if (activeTab === "cloud_scapes") return scape.source === "cloud" + return false + }) const handleDeleteClick = (e: React.MouseEvent, scape: Scape) => { e.stopPropagation() @@ -59,7 +65,7 @@ export default function Dashboard() { const confirmDelete = async () => { if (!scapeToDelete) return - await deleteScape(scapeToDelete.id!) + await deleteScape(scapeToDelete) setScapeToDelete(null) setDeleteConfirmation("") } @@ -91,14 +97,20 @@ export default function Dashboard() { ) } - // Default: My Scapes (Dashboard) + // Default: My Scapes (Dashboard) - Now Local or Cloud + const isCloud = activeTab === "cloud_scapes" + return (
{/* Toolbar */}
-

My Scapes

-

Manage your local projects

+

+ {isCloud ? "Cloud Scapes" : "Local Scapes"} +

+

+ {isCloud ? "Manage your cloud projects" : "Manage your local projects"} +

diff --git a/src/pages/ScapeEditor.tsx b/src/pages/ScapeEditor.tsx index 0f758e8a..520785c6 100644 --- a/src/pages/ScapeEditor.tsx +++ b/src/pages/ScapeEditor.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react" import { useParams } from "react-router-dom" -import { useLiveQuery } from "dexie-react-hooks" import * as ResizablePrimitive from "react-resizable-panels" import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable" @@ -13,11 +12,12 @@ import { PackagePane } from "@/components/editor/PackagePane" import { TerminalPane, type TerminalTab } from "@/components/editor/TerminalPane" import { EditorActivityBar } from "@/components/layout/EditorActivityBar" +import { SaveStatus } from "@/components/editor/SaveStatus" import type { ScapeFile } from "@/types/file" import type { Problem } from "@/types/problem" import type { LogEntry } from "@/types/log" -import { db } from "@/lib/db" import { useFileSystem } from "@/hooks/useFileSystem" +import { useScapeLoading } from "@/hooks/useScapeLoading" import { useDebounce } from "@/hooks/useDebounce" import { buildFileTree, type FileNode } from "@/lib/file-tree" import { @@ -96,21 +96,23 @@ const getLayout = (key: string, defaults: number[]) => { export default function ScapeEditor() { const { scapeId } = useParams() - // POLYMORPHIC ID LOGIC: - // If the URL ID is numeric (e.g. "123"), treat it as a number (legacy). - // If `scapeId` is undefined/UUID, keep as string. - const rawId = scapeId || "" - const isNumericId = !isNaN(Number(rawId)) && rawId.trim() !== "" - const id = isNumericId ? Number(rawId) : rawId + // STRICT UUID LOGIC (Clean Slate) + const id = scapeId || "" // Load Scape and Files - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const scape = useLiveQuery(() => db.scapes.get(id as any), [id]) // Cast as any because Dexie get usually expects specific type, but Key is valid - - const { files, isInitialized, createFile, updateFile, deleteFile, bulkRename } = useFileSystem( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - id as any - ) + const { scape, source, emitUpdate } = useScapeLoading(id) + + const { + files, + isInitialized, + createFile, + updateFile, + deleteFile, + bulkRename, + updateScape, + saveState, + lastSaved, + } = useFileSystem(id, source) // Local State // Preview Collapsed State (Lifted to top for safety) @@ -119,6 +121,9 @@ export default function ScapeEditor() { const [isRunnerBusy, setIsRunnerBusy] = useState(false) const [isPreviewOpen, setIsPreviewOpen] = usePersistentState("scape-preview-open", true) + // Optimistic UI for Dependencies + const [optimisticDependencies, setOptimisticDependencies] = useState(null) + const [debouncedFiles, setDebouncedFiles] = useState([]) const [initialPreviewSynced, setInitialPreviewSynced] = useState(false) const [itemToDelete, setItemToDelete] = useState(null) @@ -287,7 +292,7 @@ export default function ScapeEditor() { await new Promise((r) => setTimeout(r, 1000)) const thumb = await previewRef.current.captureThumbnail() if (thumb) { - await db.scapes.update(id, { thumbnail: thumb }) + await updateScape({ thumbnail: thumb }) lastCaptureRef.current = Date.now() console.log("Thumbnail Captured") } @@ -298,7 +303,7 @@ export default function ScapeEditor() { } capture() - }, [debouncedFiles, id, autoRefresh, isPreviewOpen]) + }, [debouncedFiles, id, autoRefresh, isPreviewOpen, updateScape]) const handleManualRefresh = useCallback(() => { setOutputLogs([]) // Clear output @@ -338,18 +343,27 @@ export default function ScapeEditor() { // 2. Persist to DB const currentDeps = scape?.dependencies || [] if (!currentDeps.includes(arg)) { - await db.scapes.update(id, { dependencies: [...currentDeps, arg] }) + // Optimistic Update + setOptimisticDependencies([...(optimisticDependencies || currentDeps), arg]) + await updateScape({ dependencies: [...currentDeps, arg] }) + // Broadcast to other clients + if (emitUpdate) emitUpdate({ dependencies: [...currentDeps, arg] }) } } return result } if (cmd === "pip-uninstall") { - // Pyodide doesn't support runtime uninstall easily. // We force a restart of the worker to reload with the new dependency list. const currentDeps = scape?.dependencies || [] const newDeps = currentDeps.filter((d) => d !== arg) - await db.scapes.update(id, { dependencies: newDeps }) + + // Optimistic Update + setOptimisticDependencies(newDeps) + + await updateScape({ dependencies: newDeps }) + // Broadcast to other clients + if (emitUpdate) emitUpdate({ dependencies: newDeps }) // Wait a tick for prop propagation (though ref-based restart pulls from parent props on re-render?) // Actually, we should trigger restart. The restart logic in PythonRunner uses a ref for dependencies. @@ -371,7 +385,7 @@ export default function ScapeEditor() { return { success: false, error: "Unknown command handling" } }, - [id, scape?.dependencies] + [scape?.dependencies, emitUpdate, optimisticDependencies, updateScape] ) const handleDeletePackage = useCallback( @@ -519,7 +533,7 @@ export default function ScapeEditor() { const handleMoveNode = async (oldPath: string, newPath: string) => { if (files.some((f) => f.name === newPath)) return - const updates: { id: number; name: string }[] = [] + const updates: { id: string; name: string }[] = [] // Rename the node itself (file or folder) const exactNode = files.find((f) => f.name === oldPath) @@ -655,6 +669,13 @@ export default function ScapeEditor() { {scape?.name} +
+ +
} @@ -792,7 +813,7 @@ export default function ScapeEditor() { scape && ENVIRONMENTS[scape.environment]?.capabilities.packages && ( )} diff --git a/src/test/file-tree.test.ts b/src/test/file-tree.test.ts index e6da5d15..8c364be9 100644 --- a/src/test/file-tree.test.ts +++ b/src/test/file-tree.test.ts @@ -10,8 +10,8 @@ describe("buildFileTree", () => { it("should create a flat list of files", () => { const files: ScapeFile[] = [ - { id: 1, name: "index.html", language: "html", content: "" }, - { id: 2, name: "style.css", language: "css", content: "" }, + { id: "1", name: "index.html", language: "html", content: "" }, + { id: "2", name: "style.css", language: "css", content: "" }, ] const result = buildFileTree(files) expect(result).toHaveLength(2) @@ -21,8 +21,8 @@ describe("buildFileTree", () => { it("should nest files inside folders", () => { const files: ScapeFile[] = [ - { id: 1, name: "src", language: "folder", content: "" }, - { id: 2, name: "src/main.ts", language: "javascript", content: "" }, + { id: "1", name: "src", language: "folder", content: "" }, + { id: "2", name: "src/main.ts", language: "javascript", content: "" }, ] // Note: The input to buildFileTree currently expects the folder to exist explicitly if we want it to be a folder type node @@ -41,7 +41,7 @@ describe("buildFileTree", () => { // Our current implementation might put it at root or create implicit folder. // Let's verify current behavior. const files: ScapeFile[] = [ - { id: 1, name: "components/Button.tsx", language: "javascript", content: "" }, + { id: "1", name: "components/Button.tsx", language: "javascript", content: "" }, ] const result = buildFileTree(files) @@ -57,8 +57,8 @@ describe("buildFileTree", () => { it("should sort folders before files", () => { const files: ScapeFile[] = [ - { id: 1, name: "utils.js", language: "javascript", content: "" }, - { id: 2, name: "assets", language: "folder", content: "" }, + { id: "1", name: "utils.js", language: "javascript", content: "" }, + { id: "2", name: "assets", language: "folder", content: "" }, ] const result = buildFileTree(files) diff --git a/src/types/file.ts b/src/types/file.ts index 120ce5d8..4930980c 100644 --- a/src/types/file.ts +++ b/src/types/file.ts @@ -10,7 +10,7 @@ export type FileType = | "binary" export interface ScapeFile { - id?: number + id?: string // Changed from number to string (UUID) name: string content: string | Blob | ArrayBuffer | Uint8Array language: FileType diff --git a/supabase/migrations/20250101_initial_schema.sql b/supabase/migrations/20250101_initial_schema.sql new file mode 100644 index 00000000..f6395fd9 --- /dev/null +++ b/supabase/migrations/20250101_initial_schema.sql @@ -0,0 +1,88 @@ +-- Enable UUID extension +create extension if not exists "uuid-ossp"; + +-- Scapes Table +create table scapes ( + id uuid primary key default uuid_generate_v4(), + name text not null, + environment text not null, + template text not null, + source text not null check (source in ('cloud')), -- Only cloud scapes live here + author_id uuid references auth.users(id) not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null, + thumbnail text, -- Base64 data URL (for now) + dependencies text[] default '{}'::text[] +); + +-- Files Table +create table files ( + id uuid primary key default uuid_generate_v4(), + scape_id uuid references scapes(id) on delete cascade not null, + name text not null, + language text not null, + content text default '', -- Text content. Binary content should use Storage. + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null, + unique(scape_id, name) +); + +-- RLS Policies +alter table scapes enable row level security; +alter table files enable row level security; + +-- Scapes: Users can view/edit their own scapes +create policy "Users can view own scapes" on scapes + for select using (auth.uid() = author_id); + +create policy "Users can insert own scapes" on scapes + for insert with check (auth.uid() = author_id); + +create policy "Users can update own scapes" on scapes + for update using (auth.uid() = author_id); + +create policy "Users can delete own scapes" on scapes + for delete using (auth.uid() = author_id); + +-- Files: Users can view/edit files of scapes they own +-- Performance optimization: We use a join or exact check. +-- For simple MVP, we trust the `scape_id` check if we cascade correctly, +-- but RLS needs to check the parent scape's author. + +create policy "Users can view files of own scapes" on files + for select using ( + exists ( select 1 from scapes where id = files.scape_id and author_id = auth.uid() ) + ); + +create policy "Users can insert files to own scapes" on files + for insert with check ( + exists ( select 1 from scapes where id = files.scape_id and author_id = auth.uid() ) + ); + +create policy "Users can update files of own scapes" on files + for update using ( + exists ( select 1 from scapes where id = files.scape_id and author_id = auth.uid() ) + ); + +create policy "Users can delete files of own scapes" on files + for delete using ( + exists ( select 1 from scapes where id = files.scape_id and author_id = auth.uid() ) + ); + +-- Storage Bucket (Optional for now, but good to have) +insert into storage.buckets (id, name, public) +values ('scape-assets', 'scape-assets', true) +on conflict (id) do nothing; + +create policy "Public Access to Scape Assets" on storage.objects + for select using ( bucket_id = 'scape-assets' ); + +create policy "Auth Upload to Scape Assets" on storage.objects + for insert with check ( bucket_id = 'scape-assets' and auth.role() = 'authenticated' ); + +-- Enable Realtime +alter publication supabase_realtime add table files; +alter publication supabase_realtime add table scapes; +-- Necessary for DELETE events and full UPDATE payloads +alter table files replica identity full; +alter table scapes replica identity full; \ No newline at end of file