Compare commits

..
5 Commits
Author SHA1 Message Date
gitea 1dc723477b get schema button 2026-03-10 16:22:26 +01:00
gitea 729fc90365 some fixes 2026-03-07 20:09:21 +01:00
gitea 44f54b4793 no registering option 2026-03-07 15:37:40 +01:00
gitea af6b7e01cd disallow registering 2026-03-07 15:20:01 +01:00
gitea aa3480af17 ok 2026-03-07 15:10:37 +01:00
7 changed files with 78 additions and 12 deletions
+3
View File
@@ -12,6 +12,9 @@ VITE_SUPABASE_ANON_KEY=your-anon-key
# Optional: for OAuth / magic link redirects # Optional: for OAuth / magic link redirects
# VITE_SUPABASE_REDIRECT_URL=http://localhost:5173 # VITE_SUPABASE_REDIRECT_URL=http://localhost:5173
# Set to "true" to disable new user registration (login still works)
# VITE_DISABLE_REGISTRATION=false
# App uses schema "omotomo" for decks/questions. Ensure the API exposes it: # App uses schema "omotomo" for decks/questions. Ensure the API exposes it:
# Self-hosted: add "omotomo" to PostgREST db-schemas (e.g. in config). # Self-hosted: add "omotomo" to PostgREST db-schemas (e.g. in config).
# Cloud: Dashboard > Project Settings > API > Exposed schemas. # Cloud: Dashboard > Project Settings > API > Exposed schemas.
+10 -1
View File
@@ -13,11 +13,20 @@ This starts both the Vite dev server (frontend) and the API server (default port
Copy `.env.sample` to `.env` and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` for the Svelte app and (optionally) the API server. Copy `.env.sample` to `.env` and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` for the Svelte app and (optionally) the API server.
## Production
```bash
npm run build
npm run start
```
This builds the frontend to `dist/` and starts the server. The same process serves both the API and the built SPA at `http://localhost:3001` (or your `PORT`). Without a `dist/` folder (e.g. API-only), the server still runs and serves only the API.
## API server (REST API for mobile / other clients) ## API server (REST API for mobile / other clients)
The Express API uses the same Supabase backend. Optional env: `SUPABASE_URL`, `SUPABASE_ANON_KEY` (default to `VITE_*`), `PORT` (default `3001`). The Express API uses the same Supabase backend. Optional env: `SUPABASE_URL`, `SUPABASE_ANON_KEY` (default to `VITE_*`), `PORT` (default `3001`).
- **Run**: `npm run dev:server` or `npm run start:api` - **Run**: `npm run dev:server`, `npm run start`, or `npm run start:api`. When `dist/` exists, the server also serves the frontend.
- **Auth**: `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/session`, `GET /api/auth/profile` (Bearer token for protected routes; profile includes `display_name`, `email`, `avatar_url`) - **Auth**: `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/session`, `GET /api/auth/profile` (Bearer token for protected routes; profile includes `display_name`, `email`, `avatar_url`)
- **Decks**: `GET /api/decks/mine`, `GET /api/decks/published`, `GET /api/decks/:id`, `POST /api/decks`, `PATCH /api/decks/:id`, `DELETE /api/decks/:id`, `POST /api/decks/:id/publish`, `POST /api/decks/:id/copy`, `GET /api/decks/:id/update-preview`, `POST /api/decks/:id/apply-update` - **Decks**: `GET /api/decks/mine`, `GET /api/decks/published`, `GET /api/decks/:id`, `POST /api/decks`, `PATCH /api/decks/:id`, `DELETE /api/decks/:id`, `POST /api/decks/:id/publish`, `POST /api/decks/:id/copy`, `GET /api/decks/:id/update-preview`, `POST /api/decks/:id/apply-update`
+1
View File
@@ -12,6 +12,7 @@
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"dev:server": "node server/index.js", "dev:server": "node server/index.js",
"start": "node server/index.js",
"start:api": "node server/index.js" "start:api": "node server/index.js"
}, },
"devDependencies": { "devDependencies": {
+21
View File
@@ -1,9 +1,16 @@
import 'dotenv/config'; import 'dotenv/config';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import * as decksApi from '../src/lib/api/decks-core.js'; import * as decksApi from '../src/lib/api/decks-core.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
const distPath = path.join(projectRoot, 'dist');
const app = express(); const app = express();
app.use(cors({ origin: true, credentials: true })); app.use(cors({ origin: true, credentials: true }));
app.use(express.json()); app.use(express.json());
@@ -358,7 +365,21 @@ app.delete('/api/decks/:id', requireAuth, async (req, res) => {
} }
}); });
// Serve built frontend when dist/ exists (production)
if (fs.existsSync(distPath)) {
app.use(express.static(distPath));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api')) return next();
res.sendFile(path.join(distPath, 'index.html'), (err) => {
if (err) next(err);
});
});
}
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`API server listening on http://localhost:${PORT}`); console.log(`API server listening on http://localhost:${PORT}`);
if (fs.existsSync(distPath)) {
console.log(`Serving frontend from dist/ (open http://localhost:${PORT})`);
}
}); });
+13 -9
View File
@@ -10,6 +10,8 @@
$: myDecksActive = loc === '/' || loc === '/decks/new' || /^\/decks\/[^/]+\/edit$/.test(loc) || $navContext === 'my-decks'; $: myDecksActive = loc === '/' || loc === '/decks/new' || /^\/decks\/[^/]+\/edit$/.test(loc) || $navContext === 'my-decks';
$: communityActive = loc === '/community' || loc.startsWith('/community/') || $navContext === 'community'; $: communityActive = loc === '/community' || loc.startsWith('/community/') || $navContext === 'community';
const registrationDisabled = import.meta.env.VITE_DISABLE_REGISTRATION === 'true';
let showPopup = false; let showPopup = false;
let mode = 'login'; // 'login' | 'register' let mode = 'login'; // 'login' | 'register'
let email = ''; let email = '';
@@ -206,14 +208,16 @@
> >
Log in Log in
</button> </button>
<button {#if !registrationDisabled}
type="button" <button
class="tab" type="button"
class:active={mode === 'register'} class="tab"
onclick={() => { mode = 'register'; auth.clearError(); registerSuccess = false; }} class:active={mode === 'register'}
> onclick={() => { mode = 'register'; auth.clearError(); registerSuccess = false; }}
Register >
</button> Register
</button>
{/if}
</div> </div>
{#if registerSuccess && !$auth.user} {#if registerSuccess && !$auth.user}
@@ -250,7 +254,7 @@
/> />
{#if $auth.error} {#if $auth.error}
<p class="auth-error">{$auth.error}</p> <p class="auth-error">{$auth.error}</p>
{#if mode === 'login'} {#if mode === 'login' && !registrationDisabled}
<p class="auth-switch"> <p class="auth-switch">
Do you have an account? <button type="button" class="auth-link" onclick={() => { mode = 'register'; auth.clearError(); }}>Register here</button>. Do you have an account? <button type="button" class="auth-link" onclick={() => { mode = 'register'; auth.clearError(); }}>Register here</button>.
</p> </p>
+1 -1
View File
@@ -90,8 +90,8 @@ function createAuthStore() {
} }
}, },
logout: async () => { logout: async () => {
await supabase.auth.signOut();
set({ user: null, loading: false, error: null }); set({ user: null, loading: false, error: null });
await supabase.auth.signOut();
}, },
clearError: () => update((s) => ({ ...s, error: null })), clearError: () => update((s) => ({ ...s, error: null })),
}; };
+29 -1
View File
@@ -130,6 +130,25 @@
function cancel() { function cancel() {
push('/'); push('/');
} }
let copied = false;
function copyJson() {
const payload = {
title,
description,
config,
questions: questions.map((q) => ({
prompt: q.prompt,
explanation: q.explanation,
answers: q.answers,
correct_answer_indices: q.correct_answer_indices,
})),
};
navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => {
copied = true;
setTimeout(() => { copied = false; }, 2000);
});
}
</script> </script>
<header class="page-header page-header-fixed"> <header class="page-header page-header-fixed">
@@ -166,7 +185,10 @@
placeholder="Paste deck JSON (title, description, config, questions)" placeholder="Paste deck JSON (title, description, config, questions)"
rows="8" rows="8"
></textarea> ></textarea>
<button type="button" class="btn btn-secondary" onclick={loadFromJson}>Load from JSON</button> <div class="json-actions">
<button type="button" class="btn btn-secondary" onclick={loadFromJson}>Load from JSON</button>
<button type="button" class="btn btn-secondary" onclick={copyJson}>{copied ? 'Copied!' : 'Copy JSON'}</button>
</div>
{#if jsonError} {#if jsonError}
<p class="error">{jsonError}</p> <p class="error">{jsonError}</p>
{/if} {/if}
@@ -291,6 +313,12 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.json-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.form-group { .form-group {
margin-bottom: 1rem; margin-bottom: 1rem;
} }