create-elytra 0.0.0 → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -4
- package/index.js +8 -5
- package/package.json +11 -4
- package/src/args.js +93 -0
- package/src/cli.js +113 -0
- package/src/prompts.js +40 -0
- package/src/scaffold.js +96 -0
- package/template/CONNECT.md +89 -0
- package/template/README.md +51 -0
- package/template/cms/blocks.ts +17 -0
- package/template/cms/collections/asset.ts +13 -0
- package/template/cms/collections/author.ts +12 -0
- package/template/cms/collections/index.ts +11 -0
- package/template/cms/collections/page.ts +45 -0
- package/template/cms/collections/post.ts +104 -0
- package/template/cms/collections/settings.ts +27 -0
- package/template/cms/index.ts +11 -0
- package/template/cms/redirects.ts +7 -0
- package/template/cms/routes.ts +34 -0
- package/template/components/index.ts +24 -0
- package/template/components/marketing/feature-card.tsx +77 -0
- package/template/components/marketing/hero.tsx +81 -0
- package/template/components/marketing/index.tsx +32 -0
- package/template/components/marketing/section.tsx +41 -0
- package/template/components/marketing/shared.ts +21 -0
- package/template/components/post-body.tsx +47 -0
- package/template/components/post-teaser.tsx +46 -0
- package/template/components/theme.css +31 -0
- package/template/dot-gitignore +34 -0
- package/template/elytra.config.ts +39 -0
- package/template/frontend/app/[[...slug]]/page.tsx +22 -0
- package/template/frontend/app/api/revalidate/route.ts +14 -0
- package/template/frontend/app/layout.tsx +14 -0
- package/template/frontend/app/not-found.tsx +8 -0
- package/template/frontend/app/sitemap.ts +22 -0
- package/template/frontend/dot-env +14 -0
- package/template/frontend/lib/content.ts +68 -0
- package/template/frontend/lib/host.ts +9 -0
- package/template/frontend/lib/live-content.ts +270 -0
- package/template/frontend/lib/project-config.ts +22 -0
- package/template/frontend/next.config.mjs +19 -0
- package/template/frontend/package.json +25 -0
- package/template/frontend/tsconfig.json +39 -0
- package/template/package.json +22 -0
- package/template/pnpm-workspace.yaml +3 -0
- package/template/studio/convex/assets.ts +1 -0
- package/template/studio/convex/auth.config.ts +3 -0
- package/template/studio/convex/auth.ts +6 -0
- package/template/studio/convex/cliTokens.ts +1 -0
- package/template/studio/convex/cms.ts +1 -0
- package/template/studio/convex/content.ts +1 -0
- package/template/studio/convex/delivery.ts +1 -0
- package/template/studio/convex/functions.ts +1 -0
- package/template/studio/convex/graphs.ts +1 -0
- package/template/studio/convex/guard.ts +1 -0
- package/template/studio/convex/http.ts +6 -0
- package/template/studio/convex/members.ts +1 -0
- package/template/studio/convex/publishing.ts +1 -0
- package/template/studio/convex/references.ts +1 -0
- package/template/studio/convex/schema.ts +1 -0
- package/template/studio/convex/sync.ts +4 -0
- package/template/studio/convex/tsconfig.json +17 -0
- package/template/studio/convex/users.ts +1 -0
- package/template/studio/convex/webhooks.ts +1 -0
- package/template/studio/dot-env +18 -0
- package/template/studio/package.json +34 -0
- package/template/studio/src/routeTree.gen.ts +104 -0
- package/template/studio/src/router.tsx +25 -0
- package/template/studio/src/routes/$projectId.$.tsx +14 -0
- package/template/studio/src/routes/__root.tsx +119 -0
- package/template/studio/src/routes/index.tsx +17 -0
- package/template/studio/src/routes/sign-in.tsx +159 -0
- package/template/studio/src/styles/app.css +11 -0
- package/template/studio/src/styles/canvas.css +23 -0
- package/template/studio/src/vite-env.d.ts +1 -0
- package/template/studio/tsconfig.json +20 -0
- package/template/studio/vite.config.ts +26 -0
- package/template/turbo.json +18 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// EC-252: Convex Auth (EC-150) is package-shipped with a default + override seam; the
|
|
2
|
+
// engine's first-principal admin bootstrap lives in the package, not here. This consumer
|
|
3
|
+
// picks up the default (email + password). Add OAuth via defineStudioAuth({ providers }).
|
|
4
|
+
import { defineStudioAuth } from '@elytracms/studio/convex/auth'
|
|
5
|
+
|
|
6
|
+
export const { auth, signIn, signOut, store, isAuthenticated } = defineStudioAuth()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/cliTokens'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/cms'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/content'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/delivery'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/functions'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/graphs'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/guard'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// EC-252: HTTP routes (Convex Auth /.well-known/* + the EC-157 registry-sync endpoint)
|
|
2
|
+
// are package-shipped; the consumer injects only its own auth (provider choice).
|
|
3
|
+
import { auth } from './auth'
|
|
4
|
+
import { defineStudioHttp } from '@elytracms/studio/convex/http'
|
|
5
|
+
|
|
6
|
+
export default defineStudioHttp({ auth })
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/members'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/publishing'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/references'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@elytracms/studio/convex/schema'
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// EC-252: the EC-157 sync endpoint is package-shipped engine infra (decoupled from
|
|
2
|
+
// ./_generated via string refs). This consumer shim keeps the convex/sync.ts path
|
|
3
|
+
// Convex bundles + codegens against, registering sync:registrySync + sync:exportSnapshot.
|
|
4
|
+
export * from '@elytracms/studio/convex/sync'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"customConditions": ["development"],
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["./**/*.ts"]
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/users'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@elytracms/studio/convex/webhooks'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# {{projectName}} — studio environment.
|
|
2
|
+
#
|
|
3
|
+
# Backend mode. Exactly one path is active:
|
|
4
|
+
#
|
|
5
|
+
# Playground (default): in-memory, seeded, no signup. The studio boots straight
|
|
6
|
+
# into an editable starter project; data resets on reload. Sign-in is bypassed.
|
|
7
|
+
#
|
|
8
|
+
# Connected: comment out VITE_ELYTRA_MODE and set VITE_CONVEX_URL to your Convex
|
|
9
|
+
# deployment (run `npx convex dev` from this directory). See CONNECT.md.
|
|
10
|
+
#
|
|
11
|
+
{{studioModeEnv}}
|
|
12
|
+
|
|
13
|
+
# --- Connected mode ----------------------------------------------------------
|
|
14
|
+
# VITE_CONVEX_URL=https://<your-deployment>.convex.cloud
|
|
15
|
+
#
|
|
16
|
+
# Optional: auto-sign-in for local testing (a REAL sign-in; role guards still
|
|
17
|
+
# apply). Format email:password.
|
|
18
|
+
# VITE_ELYTRA_DEV_LOGIN=you@example.com:your-password
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "studio",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"type-check": "tsc --noEmit",
|
|
10
|
+
"convex:dev": "convex dev"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@auth/core": "^0.37.4",
|
|
14
|
+
"@convex-dev/auth": "^0.0.94",
|
|
15
|
+
"@elytracms/core": "^0.0.7",
|
|
16
|
+
"@elytracms/studio": "^0.0.7",
|
|
17
|
+
"@tailwindcss/vite": "^4.3.0",
|
|
18
|
+
"@tanstack/react-router": "^1.170.0",
|
|
19
|
+
"@tanstack/react-start": "^1.168.0",
|
|
20
|
+
"convex": "^1.40.0",
|
|
21
|
+
"react": "^19.0.0",
|
|
22
|
+
"react-dom": "^19.0.0",
|
|
23
|
+
"tailwindcss": "^4.3.0",
|
|
24
|
+
"zod": "^4.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"@types/react": "^19.0.0",
|
|
29
|
+
"@types/react-dom": "^19.0.0",
|
|
30
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
31
|
+
"typescript": "^5.7.0",
|
|
32
|
+
"vite": "^7.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
// noinspection JSUnusedGlobalSymbols
|
|
6
|
+
|
|
7
|
+
// This file was automatically generated by TanStack Router.
|
|
8
|
+
// You should NOT make any changes in this file as it will be overwritten.
|
|
9
|
+
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
|
10
|
+
|
|
11
|
+
import { Route as rootRouteImport } from './routes/__root'
|
|
12
|
+
import { Route as SignInRouteImport } from './routes/sign-in'
|
|
13
|
+
import { Route as IndexRouteImport } from './routes/index'
|
|
14
|
+
import { Route as ProjectIdSplatRouteImport } from './routes/$projectId.$'
|
|
15
|
+
|
|
16
|
+
const SignInRoute = SignInRouteImport.update({
|
|
17
|
+
id: '/sign-in',
|
|
18
|
+
path: '/sign-in',
|
|
19
|
+
getParentRoute: () => rootRouteImport,
|
|
20
|
+
} as any)
|
|
21
|
+
const IndexRoute = IndexRouteImport.update({
|
|
22
|
+
id: '/',
|
|
23
|
+
path: '/',
|
|
24
|
+
getParentRoute: () => rootRouteImport,
|
|
25
|
+
} as any)
|
|
26
|
+
const ProjectIdSplatRoute = ProjectIdSplatRouteImport.update({
|
|
27
|
+
id: '/$projectId/$',
|
|
28
|
+
path: '/$projectId/$',
|
|
29
|
+
getParentRoute: () => rootRouteImport,
|
|
30
|
+
} as any)
|
|
31
|
+
|
|
32
|
+
export interface FileRoutesByFullPath {
|
|
33
|
+
'/': typeof IndexRoute
|
|
34
|
+
'/sign-in': typeof SignInRoute
|
|
35
|
+
'/$projectId/$': typeof ProjectIdSplatRoute
|
|
36
|
+
}
|
|
37
|
+
export interface FileRoutesByTo {
|
|
38
|
+
'/': typeof IndexRoute
|
|
39
|
+
'/sign-in': typeof SignInRoute
|
|
40
|
+
'/$projectId/$': typeof ProjectIdSplatRoute
|
|
41
|
+
}
|
|
42
|
+
export interface FileRoutesById {
|
|
43
|
+
__root__: typeof rootRouteImport
|
|
44
|
+
'/': typeof IndexRoute
|
|
45
|
+
'/sign-in': typeof SignInRoute
|
|
46
|
+
'/$projectId/$': typeof ProjectIdSplatRoute
|
|
47
|
+
}
|
|
48
|
+
export interface FileRouteTypes {
|
|
49
|
+
fileRoutesByFullPath: FileRoutesByFullPath
|
|
50
|
+
fullPaths: '/' | '/sign-in' | '/$projectId/$'
|
|
51
|
+
fileRoutesByTo: FileRoutesByTo
|
|
52
|
+
to: '/' | '/sign-in' | '/$projectId/$'
|
|
53
|
+
id: '__root__' | '/' | '/sign-in' | '/$projectId/$'
|
|
54
|
+
fileRoutesById: FileRoutesById
|
|
55
|
+
}
|
|
56
|
+
export interface RootRouteChildren {
|
|
57
|
+
IndexRoute: typeof IndexRoute
|
|
58
|
+
SignInRoute: typeof SignInRoute
|
|
59
|
+
ProjectIdSplatRoute: typeof ProjectIdSplatRoute
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
declare module '@tanstack/react-router' {
|
|
63
|
+
interface FileRoutesByPath {
|
|
64
|
+
'/sign-in': {
|
|
65
|
+
id: '/sign-in'
|
|
66
|
+
path: '/sign-in'
|
|
67
|
+
fullPath: '/sign-in'
|
|
68
|
+
preLoaderRoute: typeof SignInRouteImport
|
|
69
|
+
parentRoute: typeof rootRouteImport
|
|
70
|
+
}
|
|
71
|
+
'/': {
|
|
72
|
+
id: '/'
|
|
73
|
+
path: '/'
|
|
74
|
+
fullPath: '/'
|
|
75
|
+
preLoaderRoute: typeof IndexRouteImport
|
|
76
|
+
parentRoute: typeof rootRouteImport
|
|
77
|
+
}
|
|
78
|
+
'/$projectId/$': {
|
|
79
|
+
id: '/$projectId/$'
|
|
80
|
+
path: '/$projectId/$'
|
|
81
|
+
fullPath: '/$projectId/$'
|
|
82
|
+
preLoaderRoute: typeof ProjectIdSplatRouteImport
|
|
83
|
+
parentRoute: typeof rootRouteImport
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rootRouteChildren: RootRouteChildren = {
|
|
89
|
+
IndexRoute: IndexRoute,
|
|
90
|
+
SignInRoute: SignInRoute,
|
|
91
|
+
ProjectIdSplatRoute: ProjectIdSplatRoute,
|
|
92
|
+
}
|
|
93
|
+
export const routeTree = rootRouteImport
|
|
94
|
+
._addFileChildren(rootRouteChildren)
|
|
95
|
+
._addFileTypes<FileRouteTypes>()
|
|
96
|
+
|
|
97
|
+
import type { getRouter } from './router.tsx'
|
|
98
|
+
import type { createStart } from '@tanstack/react-start'
|
|
99
|
+
declare module '@tanstack/react-start' {
|
|
100
|
+
interface Register {
|
|
101
|
+
ssr: true
|
|
102
|
+
router: Awaited<ReturnType<typeof getRouter>>
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createRouter } from '@tanstack/react-router'
|
|
2
|
+
import config from '../../elytra.config'
|
|
3
|
+
import { createStudio } from '@elytracms/studio/lib/workspace'
|
|
4
|
+
import { setCanvasStylesheetUrl } from '@elytracms/studio/components/builder/canvas-stylesheet'
|
|
5
|
+
import canvasCss from './styles/canvas.css?url'
|
|
6
|
+
import { routeTree } from './routeTree.gen'
|
|
7
|
+
|
|
8
|
+
// EC-223 mount contract: the entry point explicitly hands the studio its project config
|
|
9
|
+
// (`../../elytra.config` — the repo root) — no `import.meta.glob` autoload. This is the
|
|
10
|
+
// thin mount; it runs at module load on both the server and client entries, seeding the
|
|
11
|
+
// workspace state before any route reads it.
|
|
12
|
+
createStudio(config)
|
|
13
|
+
|
|
14
|
+
// The canvas previews inside an isolated iframe and <link>s `canvas.css` — a SEPARATE
|
|
15
|
+
// stylesheet from the chrome's `app.css`: studio CSS (for the portaled editing controls)
|
|
16
|
+
// + the consuming app's delivery theme (for the blocks). Keeping it separate is what lets
|
|
17
|
+
// the delivery theme use natural global rules without leaking into the studio chrome.
|
|
18
|
+
setCanvasStylesheetUrl(canvasCss)
|
|
19
|
+
|
|
20
|
+
export function getRouter() {
|
|
21
|
+
return createRouter({
|
|
22
|
+
routeTree,
|
|
23
|
+
scrollRestoration: true,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { StudioProjectScreen } from '@elytracms/studio/components/studio-project-screen'
|
|
3
|
+
import { studioSearch } from '@elytracms/studio/router/search'
|
|
4
|
+
|
|
5
|
+
// EC-223 Stage 6 (catch-all routing): the whole `/$projectId/*` subtree — every
|
|
6
|
+
// section, the bare project index, and unknown URLs — resolves through this ONE
|
|
7
|
+
// splat. The studio package owns the URL→screen map (`router/match.ts` +
|
|
8
|
+
// `StudioProjectScreen`); this consumer file is the thin mount, the only
|
|
9
|
+
// per-project route the app defines. `validateSearch` is the engine's superset
|
|
10
|
+
// parser so deep-link params (`state`/`asset`/`field`/`page`) survive on any URL.
|
|
11
|
+
export const Route = createFileRoute('/$projectId/$')({
|
|
12
|
+
validateSearch: studioSearch,
|
|
13
|
+
component: StudioProjectScreen,
|
|
14
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
createRootRoute,
|
|
4
|
+
HeadContent,
|
|
5
|
+
Navigate,
|
|
6
|
+
Outlet,
|
|
7
|
+
Scripts,
|
|
8
|
+
useLocation,
|
|
9
|
+
} from '@tanstack/react-router'
|
|
10
|
+
import { AuthProvider, useAuth } from '@elytracms/studio/components/auth-context'
|
|
11
|
+
import { BackendGate } from '@elytracms/studio/components/backend-gate'
|
|
12
|
+
import { StudioProvider } from '@elytracms/studio/components/studio-context'
|
|
13
|
+
import { ThemeProvider } from '@elytracms/studio/components/theme-context'
|
|
14
|
+
import { WorkspaceGate } from '@elytracms/studio/components/workspace-gate'
|
|
15
|
+
import { Text } from '@elytracms/studio/ui'
|
|
16
|
+
import { SIGN_IN_PATH, resolveRouteGuard } from '@elytracms/studio/lib/auth'
|
|
17
|
+
import { activeSlugForLocation, setActiveProjectSlug } from '@elytracms/studio/lib/workspace'
|
|
18
|
+
import { THEME_INIT_SCRIPT } from '@elytracms/studio/lib/theme'
|
|
19
|
+
import appCss from '../styles/app.css?url'
|
|
20
|
+
|
|
21
|
+
export const Route = createRootRoute({
|
|
22
|
+
head: () => ({
|
|
23
|
+
meta: [
|
|
24
|
+
{ charSet: 'utf-8' },
|
|
25
|
+
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
26
|
+
{ title: 'Elytra' },
|
|
27
|
+
],
|
|
28
|
+
links: [{ rel: 'stylesheet', href: appCss }],
|
|
29
|
+
}),
|
|
30
|
+
component: RootComponent,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function RootComponent() {
|
|
34
|
+
const location = useLocation()
|
|
35
|
+
// EC-179: the active project selects its Convex backend. Resolve it from the
|
|
36
|
+
// URL (or the sign-in redirect target) and bind it *before* the auth and
|
|
37
|
+
// persistence adapters are selected, so the initial sign-in authenticates
|
|
38
|
+
// against the project the user is heading for. Keying the auth/studio subtree
|
|
39
|
+
// by the active project remounts it on a project switch, so the adapters
|
|
40
|
+
// re-select against the new backend and the session is restored per-backend.
|
|
41
|
+
const activeSlug = activeSlugForLocation(
|
|
42
|
+
location.pathname,
|
|
43
|
+
(location.search as { redirect?: string }).redirect,
|
|
44
|
+
)
|
|
45
|
+
setActiveProjectSlug(activeSlug)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<RootDocument>
|
|
49
|
+
<ThemeProvider>
|
|
50
|
+
{/* EC-153: the backend gate runs before auth — a misconfigured or
|
|
51
|
+
unreachable backend is a visible boot state, never an in-memory
|
|
52
|
+
fallback (selectAuthAdapter/selectPersistenceAdapter would throw). */}
|
|
53
|
+
<BackendGate>
|
|
54
|
+
{/* EC-154: an invalid workspace config is a deterministic boot error,
|
|
55
|
+
rendered before auth — the studio never silently falls back to
|
|
56
|
+
unconfigured (adapter-listed) behavior while a config is present. */}
|
|
57
|
+
<WorkspaceGate>
|
|
58
|
+
<AuthProvider key={activeSlug ?? '__launcher__'}>
|
|
59
|
+
<AuthGate>
|
|
60
|
+
<StudioProvider>
|
|
61
|
+
<Outlet />
|
|
62
|
+
</StudioProvider>
|
|
63
|
+
</AuthGate>
|
|
64
|
+
</AuthProvider>
|
|
65
|
+
</WorkspaceGate>
|
|
66
|
+
</BackendGate>
|
|
67
|
+
</ThemeProvider>
|
|
68
|
+
</RootDocument>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Route guard (EC-150): one chokepoint in the root layout. Unauthenticated
|
|
74
|
+
* visitors on protected paths are redirected to the sign-in screen (with the
|
|
75
|
+
* intended target preserved); signed-in users never see the sign-in screen.
|
|
76
|
+
* The decision logic is the pure, unit-tested `resolveRouteGuard`.
|
|
77
|
+
*/
|
|
78
|
+
function AuthGate({ children }: { children: ReactNode }) {
|
|
79
|
+
const { status } = useAuth()
|
|
80
|
+
const { pathname } = useLocation()
|
|
81
|
+
const decision = resolveRouteGuard(pathname, status)
|
|
82
|
+
|
|
83
|
+
if (decision.kind === 'pending') return <SessionLoading />
|
|
84
|
+
if (decision.kind === 'redirect-to-sign-in') {
|
|
85
|
+
return <Navigate to={SIGN_IN_PATH} search={{ redirect: decision.redirect }} replace />
|
|
86
|
+
}
|
|
87
|
+
if (decision.kind === 'redirect-home') return <Navigate to="/" replace />
|
|
88
|
+
return children
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Deterministic session-restore state (also the SSR output for protected paths). */
|
|
92
|
+
function SessionLoading() {
|
|
93
|
+
return (
|
|
94
|
+
<div className="grid min-h-dvh place-items-center bg-background">
|
|
95
|
+
<Text size="sm" tone="muted">
|
|
96
|
+
Checking session…
|
|
97
|
+
</Text>
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function RootDocument({ children }: { children: ReactNode }) {
|
|
103
|
+
// Dark-first SSR baseline; THEME_INIT_SCRIPT flips the class pre-hydration
|
|
104
|
+
// from the stored preference, and ThemeProvider keeps it reactive thereafter.
|
|
105
|
+
// suppressHydrationWarning: the script intentionally mutates the class before
|
|
106
|
+
// React hydrates, so a className mismatch on <html> is expected, not a bug.
|
|
107
|
+
return (
|
|
108
|
+
<html lang="de" className="dark" suppressHydrationWarning>
|
|
109
|
+
<head>
|
|
110
|
+
<HeadContent />
|
|
111
|
+
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
|
112
|
+
</head>
|
|
113
|
+
<body className="min-h-screen bg-background font-sans text-foreground antialiased">
|
|
114
|
+
{children}
|
|
115
|
+
<Scripts />
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { ProjectLauncher } from '@elytracms/studio/components/project-list'
|
|
3
|
+
import { parseSectionState } from '@elytracms/studio/components/section-surface'
|
|
4
|
+
import type { SectionState } from '@elytracms/core/studio-core'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/')({
|
|
7
|
+
validateSearch: (search: Record<string, unknown>): { state?: SectionState } => {
|
|
8
|
+
const state = parseSectionState(search.state)
|
|
9
|
+
return state ? { state } : {}
|
|
10
|
+
},
|
|
11
|
+
component: HomePage,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function HomePage() {
|
|
15
|
+
const { state } = Route.useSearch()
|
|
16
|
+
return <ProjectLauncher forcedState={state ?? null} />
|
|
17
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import type { FormEvent } from 'react'
|
|
4
|
+
import { Button, Card, CardContent, Field, Heading, Input, Stack, Text } from '@elytracms/studio/ui'
|
|
5
|
+
import { useAuth } from '@elytracms/studio/components/auth-context'
|
|
6
|
+
import { LOCAL_DEV_CREDENTIALS, authMode, postSignInTarget } from '@elytracms/studio/lib/auth'
|
|
7
|
+
import { getActiveProjectSlug, getWorkspaceState } from '@elytracms/studio/lib/workspace'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sign-in screen (EC-150). Deterministic states: idle → submitting →
|
|
11
|
+
* (signed-in redirect | inline structured failure). Invalid credentials are a
|
|
12
|
+
* rendered state, never a silent failure or a crash. In local (in-memory) mode
|
|
13
|
+
* the seeded dev credentials are listed so the whole flow works offline.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const Route = createFileRoute('/sign-in')({
|
|
17
|
+
validateSearch: (search: Record<string, unknown>): { redirect?: string } => {
|
|
18
|
+
const redirect = search.redirect
|
|
19
|
+
return typeof redirect === 'string' && redirect.length > 0 ? { redirect } : {}
|
|
20
|
+
},
|
|
21
|
+
component: SignInPage,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
type FormPhase = 'idle' | 'submitting'
|
|
25
|
+
type AuthMode = 'signin' | 'signup'
|
|
26
|
+
|
|
27
|
+
/** The project whose backend this sign-in authenticates against (EC-179). */
|
|
28
|
+
function activeProjectName(): string | null {
|
|
29
|
+
const state = getWorkspaceState()
|
|
30
|
+
if (state.kind !== 'config') return null
|
|
31
|
+
const slug = getActiveProjectSlug()
|
|
32
|
+
const project = (slug && state.projects.find((p) => p.slug === slug)) || state.projects[0]
|
|
33
|
+
return project?.name ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function SignInPage() {
|
|
37
|
+
const auth = useAuth()
|
|
38
|
+
const navigate = useNavigate()
|
|
39
|
+
const { redirect } = Route.useSearch()
|
|
40
|
+
const [email, setEmail] = useState('')
|
|
41
|
+
const [password, setPassword] = useState('')
|
|
42
|
+
const [phase, setPhase] = useState<FormPhase>('idle')
|
|
43
|
+
const [mode, setMode] = useState<AuthMode>('signin')
|
|
44
|
+
const [error, setError] = useState<string | null>(null)
|
|
45
|
+
const projectName = activeProjectName()
|
|
46
|
+
|
|
47
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
setPhase('submitting')
|
|
50
|
+
setError(null)
|
|
51
|
+
const result = mode === 'signup' ? await auth.signUp(email, password) : await auth.signIn(email, password)
|
|
52
|
+
if (result.ok) {
|
|
53
|
+
await navigate({ to: postSignInTarget(redirect) })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
// A fresh backend has no account yet: nudge sign-in → create, and vice versa.
|
|
57
|
+
if (result.reason === 'account-exists') setMode('signin')
|
|
58
|
+
setError(result.message)
|
|
59
|
+
setPhase('idle')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const submitting = phase === 'submitting'
|
|
63
|
+
const creating = mode === 'signup'
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="grid min-h-dvh place-items-center bg-background p-6">
|
|
67
|
+
<Stack gap="4" className="w-full max-w-sm">
|
|
68
|
+
<Stack gap="1" align="center">
|
|
69
|
+
<span className="size-8 rounded-lg bg-primary shadow-sm" aria-hidden />
|
|
70
|
+
<Heading size="h2">
|
|
71
|
+
{creating ? 'Create your account' : 'Sign in to Elytra'}
|
|
72
|
+
</Heading>
|
|
73
|
+
<Text size="sm" tone="muted">
|
|
74
|
+
{projectName
|
|
75
|
+
? `${creating ? 'Register on' : 'Continue to'} ${projectName}.`
|
|
76
|
+
: 'Use your studio account to continue.'}
|
|
77
|
+
</Text>
|
|
78
|
+
</Stack>
|
|
79
|
+
|
|
80
|
+
<Card>
|
|
81
|
+
<CardContent className="pt-5">
|
|
82
|
+
<form onSubmit={handleSubmit} noValidate>
|
|
83
|
+
<Stack gap="4">
|
|
84
|
+
<Field label="Email" htmlFor="sign-in-email" required>
|
|
85
|
+
<Input
|
|
86
|
+
id="sign-in-email"
|
|
87
|
+
type="email"
|
|
88
|
+
autoComplete="email"
|
|
89
|
+
value={email}
|
|
90
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
91
|
+
placeholder="you@example.com"
|
|
92
|
+
disabled={submitting}
|
|
93
|
+
/>
|
|
94
|
+
</Field>
|
|
95
|
+
<Field label="Password" htmlFor="sign-in-password" required error={error}>
|
|
96
|
+
<Input
|
|
97
|
+
id="sign-in-password"
|
|
98
|
+
type="password"
|
|
99
|
+
autoComplete={creating ? 'new-password' : 'current-password'}
|
|
100
|
+
value={password}
|
|
101
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
102
|
+
disabled={submitting}
|
|
103
|
+
/>
|
|
104
|
+
</Field>
|
|
105
|
+
<Button type="submit" disabled={submitting} className="w-full">
|
|
106
|
+
{submitting
|
|
107
|
+
? creating
|
|
108
|
+
? 'Creating account…'
|
|
109
|
+
: 'Signing in…'
|
|
110
|
+
: creating
|
|
111
|
+
? 'Create account'
|
|
112
|
+
: 'Sign in'}
|
|
113
|
+
</Button>
|
|
114
|
+
</Stack>
|
|
115
|
+
</form>
|
|
116
|
+
</CardContent>
|
|
117
|
+
</Card>
|
|
118
|
+
|
|
119
|
+
{authMode() === 'convex' ? (
|
|
120
|
+
<Text size="sm" tone="muted" className="text-center">
|
|
121
|
+
{creating ? 'Already have an account on this backend? ' : 'First time on this backend? '}
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
className="font-medium text-primary underline-offset-2 hover:underline"
|
|
125
|
+
onClick={() => {
|
|
126
|
+
setMode(creating ? 'signin' : 'signup')
|
|
127
|
+
setError(null)
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{creating ? 'Sign in' : 'Create an account'}
|
|
131
|
+
</button>
|
|
132
|
+
</Text>
|
|
133
|
+
) : null}
|
|
134
|
+
|
|
135
|
+
{authMode() === 'playground' ? <DevCredentialsHint /> : null}
|
|
136
|
+
</Stack>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Seeded dev accounts for the explicit playground mode — fixtures, not secrets. */
|
|
142
|
+
function DevCredentialsHint() {
|
|
143
|
+
return (
|
|
144
|
+
<Card className="border-dashed">
|
|
145
|
+
<CardContent className="pt-4">
|
|
146
|
+
<Stack gap="1">
|
|
147
|
+
<Text size="xs" tone="muted" className="font-medium">
|
|
148
|
+
Playground mode — seeded dev accounts
|
|
149
|
+
</Text>
|
|
150
|
+
{LOCAL_DEV_CREDENTIALS.map((entry) => (
|
|
151
|
+
<Text key={entry.user.id} size="xs" tone="muted" className="font-mono">
|
|
152
|
+
{entry.user.email} / {entry.password}
|
|
153
|
+
</Text>
|
|
154
|
+
))}
|
|
155
|
+
</Stack>
|
|
156
|
+
</CardContent>
|
|
157
|
+
</Card>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The studio CHROME stylesheet (linked by `routes/__root.tsx`). A thin re-export of
|
|
3
|
+
* the package's complete Tailwind entry (theme + tokens + base + utilities + the
|
|
4
|
+
* package `@source`) — the studio UI and nothing else. This app's own route files are
|
|
5
|
+
* auto-detected by Tailwind from the build root, so they need no `@source` here.
|
|
6
|
+
*
|
|
7
|
+
* The page-builder CANVAS does NOT use this file — its preview iframe is a separate,
|
|
8
|
+
* isolated document with its own stylesheet (`canvas.css`), so the consuming app's
|
|
9
|
+
* delivery theme never has to worry about leaking into this chrome. See `canvas.css`.
|
|
10
|
+
*/
|
|
11
|
+
@import '@elytracms/studio/styles.css';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The page-builder CANVAS stylesheet — `<link>`ed into the preview iframe only
|
|
3
|
+
* (`router.tsx` → `setCanvasStylesheetUrl`), a document fully isolated from the studio
|
|
4
|
+
* chrome. It carries BOTH halves the iframe needs:
|
|
5
|
+
*
|
|
6
|
+
* 1. the studio design system — the inline editing controls (RichTextToolbar,
|
|
7
|
+
* SpacingHandles) are portaled INTO this iframe and are styled with studio
|
|
8
|
+
* utilities, so they need it here;
|
|
9
|
+
* 2. the consuming app's DELIVERY theme — the page-builder blocks
|
|
10
|
+
* (`../../../components/*.tsx`) are the consuming app's own components and render
|
|
11
|
+
* against its delivery CSS, NOT the studio's.
|
|
12
|
+
*
|
|
13
|
+
* Because this file lives only in the isolated iframe (the chrome links `app.css`), the
|
|
14
|
+
* delivery theme keeps its natural global `body`/`h1-h6` rules with zero risk of bleeding
|
|
15
|
+
* into the studio UI — the developer authors `components/theme.css` as plain site CSS.
|
|
16
|
+
*
|
|
17
|
+
* `@source` is required: Tailwind only generates classes it can see, and it never scans
|
|
18
|
+
* the cross-dir host components, so without this their block utilities are purged.
|
|
19
|
+
*/
|
|
20
|
+
@import '@elytracms/studio/styles.css';
|
|
21
|
+
@import '../../../components/theme.css';
|
|
22
|
+
|
|
23
|
+
@source '../../../components/**/*.{ts,tsx}';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"customConditions": ["development"],
|
|
17
|
+
"types": ["node", "vite/client"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src", "src/routeTree.gen.ts", "vite.config.ts"]
|
|
20
|
+
}
|