├── .gitignore ├── supabase ├── .gitignore ├── .env.example ├── functions │ ├── .vscode │ │ ├── extensions.json │ │ └── settings.json │ ├── _shared │ │ └── cors.ts │ ├── import_map.json │ ├── generate-diagram │ │ └── index.ts │ └── hanko-auth │ │ └── index.ts ├── seed.sql ├── migrations │ ├── 20231022103806_platform_realtime_replication.sql │ ├── 20231022124927_add_project_view.sql │ ├── 20231022073236_integrate_platform_limits.sql │ └── 20231020190554_schema_init.sql └── config.toml ├── bun.lockb ├── docs ├── 1.png ├── 2.png ├── login-1.png ├── login-2.png ├── profile-1.png └── local_development.md ├── src ├── core │ ├── state │ │ ├── constants.ts │ │ ├── plugins │ │ │ └── withLogger.ts │ │ ├── hanko.ts │ │ ├── platform.ts │ │ └── auth.ts │ ├── utils │ │ ├── id.ts │ │ ├── cookieStorage.ts │ │ ├── breakpoint.ts │ │ ├── date.ts │ │ ├── controlledDialog.ts │ │ ├── umami.ts │ │ └── export.ts │ ├── services │ │ ├── platform.ts │ │ ├── auth.ts │ │ ├── gpt.ts │ │ └── projects.ts │ ├── supabase.ts │ └── constants │ │ └── diagrams.ts ├── assets │ └── favicon.ico ├── mocks │ ├── handler.ts │ └── data │ │ ├── log.ts │ │ ├── access-token.ts │ │ └── hanko.ts ├── components │ ├── Auth │ │ ├── hanko-auth-overrides.css │ │ ├── ProfileDialog.tsx │ │ ├── HankoAuth.tsx │ │ ├── HankoProfile.tsx │ │ ├── hanko-profile-overrides.css │ │ ├── HankoProfile.css.ts │ │ ├── HankoAuth.css.ts │ │ ├── Auth.css.ts │ │ └── Auth.tsx │ ├── Projects │ │ ├── ProjectEditor │ │ │ ├── ProjectEditorShowOnEditable │ │ │ │ └── ProjectEditorShowOnEditable.tsx │ │ │ ├── ProjectEditorSidebar │ │ │ │ ├── ProjectEditorSidebar.css.ts │ │ │ │ └── ProjectEditorSidebar.tsx │ │ │ ├── ProjectEditor.css.ts │ │ │ ├── ProjectEditorToolbar │ │ │ │ ├── PageActionToolbar.tsx │ │ │ │ ├── DiagramActionToolbar.tsx │ │ │ │ └── ProjectEditorToolbar.tsx │ │ │ ├── ProjectEditorNoPagesContent │ │ │ │ └── ProjectEditorNoPagesContent.tsx │ │ │ ├── previewState.ts │ │ │ ├── ProjectEditorHeader.tsx │ │ │ ├── ProjectEditor.tsx │ │ │ ├── ProjectEditorNewPageDialog │ │ │ │ └── ProjectEditorNewPageDialog.tsx │ │ │ ├── ProjectEditorPageSettingsDialog │ │ │ │ └── ProjectEditorPageSettingsDialog.tsx │ │ │ ├── ProjectEditorContent │ │ │ │ └── ProjectEditorContent.tsx │ │ │ └── ProjectEditorExportDialog │ │ │ │ └── ProjectEditorExportDiagramDialog.tsx │ │ ├── ProjectCard │ │ │ └── ProjectCard.tsx │ │ ├── NewProjectDialog │ │ │ └── NewProjectDialog.tsx │ │ ├── ProjectEditSettingsDialog │ │ │ └── ProjectEditSettingsDialog.tsx │ │ └── Projects.tsx │ ├── NotFound │ │ └── NotFound.tsx │ ├── Footer │ │ └── Footer.tsx │ ├── DiagramEditor │ │ ├── DiagramEditor.tsx │ │ └── MermaidPreview.tsx │ ├── PageEditor │ │ ├── PageEditorPreview.tsx │ │ ├── PageEditor.tsx │ │ └── tiptap-plugins.ts │ └── Editor │ │ ├── readonly-ranges.ts │ │ ├── Editor.tsx │ │ ├── MarkdownEditor.tsx │ │ ├── MermaidEditor.tsx │ │ └── theme.ts ├── index.css ├── ui │ ├── UserBadge │ │ ├── UserBadge.css.ts │ │ └── CurrentUserBadge.tsx │ ├── SplitView │ │ ├── SplitView.css.ts │ │ └── SplitView.tsx │ ├── SegmentedControl │ │ ├── SegmentedControl.tsx │ │ └── SegmentedControl.css.ts │ ├── DynamicSizedContainer │ │ └── DynamicSizedContainer.tsx │ └── ConfirmDialog │ │ └── ConfirmDialog.tsx ├── icons │ ├── PlusIcon.tsx │ ├── CloseIcon.tsx │ ├── ShareIcon.tsx │ ├── LeftArrowIcon.tsx │ ├── UserCircle.tsx │ ├── EllipsisIcon.tsx │ ├── MenuBars3Icon.tsx │ ├── CodeIcon.tsx │ ├── ReloadIcon.tsx │ ├── DocumentTextIcon.tsx │ ├── TrashIcon.tsx │ ├── CogIcon.tsx │ ├── PresentationChart.tsx │ ├── SparklesIcon.tsx │ └── LoadingCircle.tsx ├── index.tsx ├── logo.svg ├── types │ ├── supabase.ts │ └── supabase.generated.ts ├── global.css.ts └── App.tsx ├── netlify.toml ├── prettier.config.cjs ├── public ├── favicon.ico └── hanko.svg ├── postcss.config.js ├── devcontainer.json ├── .idea ├── code-comments.xml ├── vcs.xml ├── .gitignore ├── modules.xml ├── prettier.xml ├── deno.xml ├── sqldialects.xml ├── codearchive.iml └── dataSources.xml ├── tailwind.config.js ├── compose-dev.yaml ├── .env.example ├── tsconfig.json ├── codearchive.code-workspace ├── index.html ├── LICENSE ├── vite.config.ts └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | .env.local 5 | 6 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | .env.local 5 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/bun.lockb -------------------------------------------------------------------------------- /docs/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/docs/1.png -------------------------------------------------------------------------------- /docs/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/docs/2.png -------------------------------------------------------------------------------- /src/core/state/constants.ts: -------------------------------------------------------------------------------- 1 | export const MOCK_AUTH_CONTEXT_KEY = "mockAuth"; 2 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/index.html" 4 | status = 200 5 | -------------------------------------------------------------------------------- /src/core/utils/id.ts: -------------------------------------------------------------------------------- 1 | export const generateUUID = () => window.crypto.randomUUID(); 2 | -------------------------------------------------------------------------------- /docs/login-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/docs/login-1.png -------------------------------------------------------------------------------- /docs/login-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/docs/login-2.png -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | }; 5 | -------------------------------------------------------------------------------- /docs/profile-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/docs/profile-1.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riccardoperra/specflow/HEAD/src/assets/favicon.ico -------------------------------------------------------------------------------- /supabase/.env.example: -------------------------------------------------------------------------------- 1 | HANKO_API_URL= 2 | PRIVATE_KEY_SUPABASE= 3 | OPENAI_TOKEN= 4 | SKIP_AUTH= 5 | -------------------------------------------------------------------------------- /supabase/functions/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["denoland.vscode-deno"] 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "postCreateCommand": [ 3 | "pnpm install", 4 | "pnpm supabase start" 5 | ] 6 | } -------------------------------------------------------------------------------- /src/mocks/handler.ts: -------------------------------------------------------------------------------- 1 | import { hankoHandlers } from "./hanko-handlers"; 2 | 3 | export const handlers = [...hankoHandlers]; 4 | -------------------------------------------------------------------------------- /supabase/seed.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO public.platform (name, max_project_row_per_user, max_project_page_per_user) 2 | VALUES ('default', 5, 10); 3 | -------------------------------------------------------------------------------- /supabase/functions/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "editor.defaultFormatter": "denoland.vscode-deno" 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Auth/hanko-auth-overrides.css: -------------------------------------------------------------------------------- 1 | .hanko_button.hanko_secondary > .hanko_loadingSpinnerWrapperIcon { 2 | justify-content: center; 3 | column-gap: 16px; 4 | } 5 | -------------------------------------------------------------------------------- /.idea/code-comments.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/core/utils/cookieStorage.ts: -------------------------------------------------------------------------------- 1 | import { cookieStorage as storage } from "@solid-primitives/storage"; 2 | import { createRoot } from "solid-js"; 3 | 4 | export const cookieStorage = createRoot(() => storage); 5 | -------------------------------------------------------------------------------- /supabase/migrations/20231022103806_platform_realtime_replication.sql: -------------------------------------------------------------------------------- 1 | begin; 2 | drop publication if exists supabase_realtime; 3 | create publication supabase_realtime 4 | for table platform; 5 | commit; 6 | -------------------------------------------------------------------------------- /supabase/functions/_shared/cors.ts: -------------------------------------------------------------------------------- 1 | export const corsHeaders = { 2 | "Access-Control-Allow-Origin": "*", 3 | "Access-Control-Allow-Headers": 4 | "authorization, x-client-info, apikey, content-type", 5 | }; 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{ts,jsx,tsx}"], 4 | theme: { 5 | fontFamily: { 6 | sans: ["Manrope, sans-serif"], 7 | }, 8 | }, 9 | plugins: [require("@tailwindcss/typography")], 10 | }; 11 | -------------------------------------------------------------------------------- /compose-dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | entrypoint: 4 | - sleep 5 | - infinity 6 | image: docker/dev-environments-javascript:stable-1 7 | init: true 8 | volumes: 9 | - type: bind 10 | source: /var/run/docker.sock 11 | target: /var/run/docker.sock 12 | 13 | -------------------------------------------------------------------------------- /supabase/functions/import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "jsonwebtoken": "https://esm.sh/jsonwebtoken@8.5.1", 4 | "jose": "https://deno.land/x/jose@v4.9.0/index.ts", 5 | "openai": "https://esm.sh/openai@4.12.1", 6 | "cookie": "https://deno.land/std@0.204.0/http/cookie.ts" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/core/utils/breakpoint.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createBreakpoints as _createBreakpoints, 3 | createMediaQuery, 4 | } from "@solid-primitives/media"; 5 | 6 | export const breakpoints = { 7 | sm: "640px", 8 | lg: "1024px", 9 | xl: "1280px", 10 | }; 11 | 12 | export const createBreakpoints = () => _createBreakpoints(breakpoints); 13 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | body { 7 | margin: 0; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | } 11 | 12 | code { 13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 14 | monospace; 15 | } 16 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/core/services/platform.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from "../supabase"; 2 | import { Database } from "../../types/supabase"; 3 | 4 | export type Platform = 5 | Database["public"]["Functions"]["get_platform_limits"]["Returns"]; 6 | 7 | export function getPlatformConfiguration() { 8 | return supabase 9 | .rpc("get_platform_limits", {}) 10 | .then((res) => res.data) as Promise; 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_HANKO_API_URL= 2 | VITE_ENABLE_AUTH_MOCK=false 3 | # If you are running supabase locally, put http://localhost:3000, 4 | # otherwise you should retrieve it from the dashboard, 5 | VITE_CLIENT_SUPABASE_URL= 6 | # If you are running supabase locally, put the `anon key` retrieved by the `pnpm supabase status` command, 7 | # otherwise you should retrieve it from the dashboard, 8 | VITE_CLIENT_SUPABASE_KEY= 9 | -------------------------------------------------------------------------------- /.idea/deno.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/core/services/auth.ts: -------------------------------------------------------------------------------- 1 | import { SessionDetail } from "@teamhanko/hanko-elements"; 2 | import { supabase } from "../supabase"; 3 | 4 | export function signSupabaseToken( 5 | session: SessionDetail, 6 | ): Promise<{ access_token: string; expiration_date: number }> { 7 | return supabase.functions 8 | .invoke("hanko-auth", { 9 | body: { token: session.jwt }, 10 | }) 11 | .then((res) => res.data); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorShowOnEditable/ProjectEditorShowOnEditable.tsx: -------------------------------------------------------------------------------- 1 | import { FlowProps, Show } from "solid-js"; 2 | import { provideState } from "statebuilder"; 3 | import { EditorState } from "../editorState"; 4 | 5 | export function ProjectEditorShowOnEditable(props: FlowProps) { 6 | const editorState = provideState(EditorState); 7 | return {props.children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/ui/UserBadge/UserBadge.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | import { themeTokens, themeVars } from "@codeui/kit"; 3 | 4 | export const badge = style({ 5 | height: "36px", 6 | borderRadius: themeTokens.radii.md, 7 | display: "flex", 8 | alignItems: "center", 9 | justifyContent: "center", 10 | lineHeight: 1, 11 | fontSize: themeTokens.fontSize.xs, 12 | padding: `0 ${themeTokens.spacing["3"]}`, 13 | gap: themeTokens.spacing["4"], 14 | }); 15 | -------------------------------------------------------------------------------- /src/icons/PlusIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function PlusIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "jsx": "preserve", 11 | "jsxImportSource": "solid-js", 12 | "types": ["vite/client"], 13 | "noEmit": true, 14 | "skipLibCheck": true 15 | }, 16 | "exclude": ["supabase/functions/**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /.idea/sqldialects.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/codearchive.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/icons/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function CloseIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/icons/ShareIcon.tsx: -------------------------------------------------------------------------------- 1 | // TODO export @codeui/kit svg 2 | 3 | import { JSX } from "solid-js"; 4 | 5 | export function ShareIcon(props: JSX.IntrinsicElements["svg"]) { 6 | return ( 7 | 14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /codearchive.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "launch": { 3 | "version": "0.2.0", 4 | "configurations": [], 5 | "compounds": [], 6 | }, 7 | "folders": [ 8 | { 9 | "name": "project-root", 10 | "path": "./" 11 | }, 12 | { 13 | "name": "supabase-functions", 14 | "path": "supabase/functions" 15 | } 16 | ], 17 | "settings": { 18 | "files.exclude": { 19 | "node_modules/": true, 20 | "app/": true, 21 | "supabase/functions/": true 22 | }, 23 | "deno.importMap": "./supabase/functions/import_map.json" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.idea/dataSources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | postgresql 6 | true 7 | org.postgresql.Driver 8 | jdbc:postgresql://localhost:54322/postgres 9 | $ProjectFileDir$ 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/mocks/data/log.ts: -------------------------------------------------------------------------------- 1 | type Color = "success" | "info" | "error" | "warning"; 2 | 3 | const colors: Record = { 4 | success: "#34d537", 5 | info: "#0099ff", 6 | error: "#da4d4d", 7 | warning: "#e88632", 8 | }; 9 | 10 | const createLog = (color: Color) => { 11 | return (...args: any[]) => { 12 | const [arg0, ...others] = args; 13 | console.info(`%c${arg0}`, `color:${colors[color]}`, ...others); 14 | }; 15 | }; 16 | 17 | export const logger = { 18 | info: createLog("info"), 19 | success: createLog("success"), 20 | error: createLog("error"), 21 | warn: createLog("warning"), 22 | }; 23 | -------------------------------------------------------------------------------- /src/icons/LeftArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@codeui/kit"; 2 | import type { SvgIconProps } from "@codeui/kit/dist/types/icons/SvgIcon"; 3 | 4 | export function LeftArrowIcon(props: SvgIconProps) { 5 | return ( 6 | 14 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/icons/UserCircle.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function UserCircle(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/NotFound/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@codeui/kit"; 2 | import { Link, useNavigate } from "@solidjs/router"; 3 | 4 | export function NotFound() { 5 | const navigate = useNavigate(); 6 | return ( 7 | 12 | 13 | The entity you're searching for does not exists. 14 | 15 | navigate("/")} 20 | > 21 | Return to home 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/icons/EllipsisIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@codeui/kit"; 2 | import { SvgIconProps } from "@codeui/kit/dist/types/icons/SvgIcon"; 3 | 4 | export function EllipsisIcon(props: SvgIconProps) { 5 | return ( 6 | 15 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/icons/MenuBars3Icon.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from "@codeui/kit"; 2 | import { SvgIconProps } from "@codeui/kit/dist/types/icons/SvgIcon"; 3 | 4 | export function MenuBars3Icon(props: SvgIconProps) { 5 | return ( 6 | 12 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /supabase/migrations/20231022124927_add_project_view.sql: -------------------------------------------------------------------------------- 1 | create 2 | or replace function public.is_user_same_as_auth_user(user_id text) returns bool as 3 | $$ 4 | BEGIN 5 | return (CASE WHEN user_id = auth.user_id() THEN 1 ELSE 0 END)::bool; 6 | END; 7 | $$ 8 | language plpgsql stable; 9 | 10 | 11 | create view project_view as 12 | ( 13 | SELECT *, public.is_user_same_as_auth_user(project.user_id) as owner 14 | FROM project); 15 | 16 | create view project_page_view as 17 | ( 18 | SELECT project_page.id, 19 | created_at, 20 | project_id, 21 | name, 22 | description, 23 | content, 24 | type, 25 | user_id, 26 | public.is_user_same_as_auth_user(project_page.user_id) as owner 27 | FROM project_page); 28 | -------------------------------------------------------------------------------- /src/icons/CodeIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function CodeIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/icons/ReloadIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function ReloadIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 11 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Auth/ProfileDialog.tsx: -------------------------------------------------------------------------------- 1 | import { HankoProfile } from "./HankoProfile"; 2 | import { Dialog, DialogPanelContent } from "@codeui/kit"; 3 | import { ControlledDialogProps } from "../../core/utils/controlledDialog"; 4 | import { createBreakpoints } from "../../core/utils/breakpoint"; 5 | 6 | export function ProfileDialog(props: ControlledDialogProps) { 7 | const breakpoint = createBreakpoints(); 8 | return ( 9 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/Auth/HankoAuth.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from "./HankoAuth.css"; 2 | import { onMount } from "solid-js"; 3 | import overrides from "./hanko-auth-overrides.css?raw"; 4 | 5 | // WC registered in src/core/hanko.ts 6 | 7 | export function HankoAuth() { 8 | let hankoAuth: HTMLElement; 9 | 10 | onMount(() => { 11 | const styleElement = document.createElement("style"); 12 | styleElement.textContent = overrides; 13 | hankoAuth.shadowRoot!.appendChild(styleElement); 14 | }); 15 | 16 | return ( 17 | (hankoAuth = ref!)} class={styles.hankoAuth} /> 18 | ); 19 | } 20 | 21 | type GlobalJsx = JSX.IntrinsicElements; 22 | 23 | declare module "solid-js" { 24 | namespace JSX { 25 | interface IntrinsicElements { 26 | "hanko-auth": GlobalJsx["hanko-auth"]; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorSidebar/ProjectEditorSidebar.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | import { responsiveStyle, themeTokens, themeVars } from "@codeui/kit"; 3 | 4 | export const sidebar = style([ 5 | { 6 | width: "280px", 7 | flex: "0 0 280px", 8 | height: "100%", 9 | padding: themeTokens.spacing["4"], 10 | paddingTop: themeTokens.spacing["2"], 11 | backgroundColor: "#151516", 12 | transition: "transform 250ms ease-in-out, margin-left 250ms ease-in-out", 13 | marginLeft: "-280px", 14 | }, 15 | responsiveStyle({ 16 | xs: { position: "absolute", left: 0, top: 0, zIndex: 20 }, 17 | sm: { position: "relative" }, 18 | }), 19 | { 20 | selectors: { 21 | "&[data-visible]": { 22 | marginLeft: "0", 23 | }, 24 | }, 25 | }, 26 | ]); 27 | -------------------------------------------------------------------------------- /src/mocks/data/access-token.ts: -------------------------------------------------------------------------------- 1 | export function buildMockAccessToken(userId: string) { 2 | const header = { 3 | alg: "RS256", 4 | kid: "d7161639-1a89-4242-b0f4-dc919c235c7d", 5 | typ: "JWT", 6 | }; 7 | 8 | const date = new Date(); 9 | date.setHours(1); 10 | const exp = date.getTime(); 11 | 12 | const payload = { 13 | aud: ["http://localhost:3000"], 14 | exp: exp, 15 | iat: exp, 16 | sub: userId, 17 | }; 18 | const encodedHeader = btoa(JSON.stringify(header)); 19 | const encodedPayload = btoa(JSON.stringify(payload)); 20 | const encodedSignature = btoa(JSON.stringify("secret")); 21 | return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; 22 | } 23 | 24 | export function parseJwt(token: string) { 25 | try { 26 | return JSON.parse(atob(token.split(".")[1])); 27 | } catch (e) { 28 | return null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditor.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | import { themeTokens, themeVars } from "@codeui/kit"; 3 | 4 | export const content = style({ 5 | backgroundColor: "#151516", 6 | flex: 1, 7 | position: "relative", 8 | paddingLeft: themeTokens.spacing["1"], 9 | minWidth: "1px", 10 | }); 11 | 12 | export const innerContent = style({ 13 | height: "100%", 14 | flex: "1", 15 | position: "relative", 16 | backgroundColor: "#111", 17 | transition: "background-color .2s", 18 | overflow: "hidden", 19 | display: "flex", 20 | flexDirection: "column", 21 | borderRadius: "22px 0 22px 22px", 22 | borderTop: `1px solid #252525`, 23 | minHeight: 0, 24 | "@media": { 25 | "(min-width: 768px)": { 26 | borderRadius: "22px 22px 0 0", 27 | border: `1px solid #252525`, 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /src/icons/DocumentTextIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function DocumentTextIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/icons/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function TrashIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/icons/CogIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function CogIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 14 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Auth/HankoProfile.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from "./HankoProfile.css"; 2 | import overrides from "./hanko-profile-overrides.css?raw"; 3 | import { onMount } from "solid-js"; 4 | 5 | // WC registered in src/core/hanko.ts 6 | 7 | export function HankoProfile() { 8 | let hankoProfile: HTMLElement; 9 | 10 | onMount(() => { 11 | const styleElement = document.createElement("style"); 12 | styleElement.textContent = overrides; 13 | hankoProfile.shadowRoot!.appendChild(styleElement); 14 | }); 15 | 16 | return ( 17 | (hankoProfile = ref!)} 20 | class={styles.hankoProfile} 21 | /> 22 | ); 23 | } 24 | 25 | type GlobalJsx = JSX.IntrinsicElements; 26 | 27 | declare module "solid-js" { 28 | namespace JSX { 29 | interface IntrinsicElements { 30 | "hanko-profile": GlobalJsx["hanko-profile"]; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/core/state/plugins/withLogger.ts: -------------------------------------------------------------------------------- 1 | import { makePlugin } from "statebuilder"; 2 | 3 | type Color = "success" | "info" | "error" | "warning"; 4 | 5 | const colors: Record = { 6 | success: "#34d537", 7 | info: "#0099ff", 8 | error: "#da4d4d", 9 | warning: "#e88632", 10 | }; 11 | 12 | const createLog = (prefix: string, color: Color) => { 13 | return (...args: any[]) => { 14 | const [arg0, ...others] = args; 15 | console.info(`%c[${prefix}] ${arg0}`, `color:${colors[color]}`, ...others); 16 | }; 17 | }; 18 | 19 | export const logger = (prefix: string) => ({ 20 | info: createLog(prefix, "info"), 21 | success: createLog(prefix, "success"), 22 | error: createLog(prefix, "error"), 23 | warn: createLog(prefix, "warning"), 24 | }); 25 | 26 | export const withLogger = (config: { name: string }) => 27 | makePlugin( 28 | (_) => ({ 29 | logger: logger(config.name), 30 | }), 31 | { name: "withLogger" }, 32 | ); 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | SpecFlow 16 | 17 | 18 | 19 | You need to enable JavaScript to run this app. 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@codeui/kit"; 2 | import { For, Match, Switch } from "solid-js"; 3 | 4 | const items = [ 5 | { 6 | name: "GitHub", 7 | href: "https://github.com/riccardoperra/specflow", 8 | type: "link", 9 | }, 10 | { 11 | name: "Made with Hanko", 12 | href: "https://hanko.io", 13 | type: "link", 14 | }, 15 | ]; 16 | 17 | export function Footer() { 18 | return ( 19 | 20 | 21 | 22 | {(item) => ( 23 | 24 | 25 | 26 | {item.name} 27 | 28 | 29 | 30 | )} 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/icons/PresentationChart.tsx: -------------------------------------------------------------------------------- 1 | // TODO export @codeui/kit svg 2 | 3 | import { JSX } from "solid-js"; 4 | 5 | export function PresentationChart(props: JSX.IntrinsicElements["svg"]) { 6 | return ( 7 | 14 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/SplitView/SplitView.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | import { recipe } from "@vanilla-extract/recipes"; 3 | import { themeTokens, themeVars } from "@codeui/kit"; 4 | 5 | export const splitView = recipe({ 6 | base: { 7 | overflow: "hidden", 8 | height: "100%", 9 | }, 10 | variants: { 11 | mode: { 12 | both: { 13 | display: "grid", 14 | gridTemplateColumns: "1fr 10px 1fr", 15 | }, 16 | single: { 17 | width: "100%", 18 | }, 19 | }, 20 | }, 21 | }); 22 | 23 | export const gutterCol = style({ 24 | gridRow: "1/-1", 25 | cursor: "col-resize", 26 | backgroundColor: themeVars.accent6, 27 | backgroundImage: 28 | "url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==)", 29 | backgroundRepeat: "no-repeat", 30 | backgroundPosition: "50%", 31 | }); 32 | 33 | export const gutterCol1 = style({ 34 | gridColumn: 2, 35 | }); 36 | -------------------------------------------------------------------------------- /src/icons/SparklesIcon.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function SparklesIcon(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Riccardo Perra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/icons/LoadingCircle.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "solid-js"; 2 | 3 | export function LoadingCircle(props: JSX.IntrinsicElements["svg"]) { 4 | return ( 5 | 12 | 20 | 25 | 26 | ); 27 | } 28 | 29 | export function LoadingCircleWithBackdrop(props: JSX.IntrinsicElements["svg"]) { 30 | return ( 31 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/core/utils/date.ts: -------------------------------------------------------------------------------- 1 | import "@formatjs/intl-relativetimeformat/polyfill"; 2 | 3 | export function formatDistanceToNow( 4 | locale: Intl.UnicodeBCP47LocaleIdentifier, 5 | value: string | Date, 6 | ): string { 7 | const diff = (new Date().getTime() - new Date(value).getTime()) / 1000; 8 | const minutes = Math.floor(diff / 60); 9 | const hours = Math.floor(minutes / 60); 10 | const days = Math.floor(hours / 24); 11 | const months = Math.floor(days / 30); 12 | const years = Math.floor(months / 12); 13 | const rtf = new Intl.RelativeTimeFormat(locale, { 14 | numeric: "always", 15 | localeMatcher: "best fit", 16 | style: "long", 17 | }); 18 | 19 | if (years > 0) { 20 | return rtf.format(0 - years, "year"); 21 | } else if (months > 0) { 22 | return rtf.format(0 - months, "month"); 23 | } else if (days > 0) { 24 | return rtf.format(0 - days, "day"); 25 | } else if (hours > 0) { 26 | return rtf.format(Math.round(0 - hours), "hour"); 27 | } else if (minutes > 0) { 28 | return rtf.format(Math.round(0 - minutes), "minute"); 29 | } else { 30 | return rtf.format(Math.round(0 - diff), "second"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorToolbar/PageActionToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { provideState } from "statebuilder"; 2 | import { EditorState } from "../editorState"; 3 | import { Button } from "@codeui/kit"; 4 | 5 | export function PageActionToolbar() { 6 | const editorState = provideState(EditorState); 7 | 8 | const saveAsMarkdown = () => { 9 | const page = editorState.selectedPage(); 10 | if (!page) { 11 | // TODO: add toast 12 | return alert("No selected page"); 13 | } 14 | const fileName = `${page.name}.md`; 15 | const file = new File([(page.content as any)["content"]], fileName, { 16 | type: "text/markdown", 17 | }); 18 | const link = document.createElement("a"); 19 | const url = URL.createObjectURL(file); 20 | link.href = url; 21 | link.download = fileName; 22 | link.click(); 23 | setTimeout(() => URL.revokeObjectURL(url), 0); 24 | }; 25 | 26 | return ( 27 | 28 | 35 | Save as Markdown 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/core/utils/controlledDialog.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createComponent, 3 | createSignal, 4 | getOwner, 5 | JSXElement, 6 | mergeProps, 7 | runWithOwner, 8 | } from "solid-js"; 9 | 10 | export interface ControlledDialogProps { 11 | isOpen: boolean; 12 | onOpenChange: (open: boolean) => void; 13 | } 14 | 15 | export function createControlledDialog() { 16 | const owner = getOwner(); 17 | 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | return ( 20 | dialogEl: (props: T) => JSXElement, 21 | props: 22 | | (( 23 | setter: (open: boolean) => void, 24 | ) => Omit) 25 | | Omit, 26 | ) => { 27 | if (!owner) return; 28 | return runWithOwner(owner, () => { 29 | const [isOpen, onOpenChange] = createSignal(true); 30 | const resolvedProps = 31 | props instanceof Function ? props(onOpenChange) : props; 32 | 33 | const propsWithDefault = mergeProps(resolvedProps, { 34 | get isOpen() { 35 | return isOpen(); 36 | }, 37 | onOpenChange, 38 | }); 39 | createComponent(dialogEl, propsWithDefault as T); 40 | }); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/UserBadge/CurrentUserBadge.tsx: -------------------------------------------------------------------------------- 1 | import { AuthState } from "../../core/state/auth"; 2 | import * as styles from "./UserBadge.css"; 3 | import { provideState } from "statebuilder"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuPortal, 9 | DropdownMenuTrigger, 10 | } from "@codeui/kit"; 11 | import { As } from "@kobalte/core"; 12 | import { UserCircle } from "../../icons/UserCircle"; 13 | 14 | export function CurrentUserBadge() { 15 | const auth = provideState(AuthState); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | {auth.get.user?.email} 23 | 24 | 25 | 26 | 27 | auth.goToProfile()}> 28 | Profile 29 | 30 | auth.logout()}> 31 | Logout 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Auth/hanko-profile-overrides.css: -------------------------------------------------------------------------------- 1 | .hanko_accordion .hanko_accordionItem .hanko_accordionContent { 2 | margin: 0 !important; 3 | } 4 | 5 | .hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked + .hanko_label ~ .hanko_accordionContent { 6 | background: var(--accordion-container-bg); 7 | margin: 0; 8 | width: auto; 9 | padding: .5rem 1.5rem; 10 | border-radius: var(--border-radius, 8px); 11 | border-top-right-radius: 0; 12 | border-top-left-radius: 0; 13 | } 14 | 15 | .hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked + .hanko_label { 16 | border-radius: var(--border-radius, 8px); 17 | border-bottom-right-radius: 0; 18 | border-bottom-left-radius: 0; 19 | } 20 | 21 | .hanko_accordion .hanko_accordionItem .hanko_label { 22 | width: 100%; 23 | } 24 | 25 | .hanko_accordion .hanko_accordionItem .hanko_label:hover.hanko_dropdown { 26 | background: var(--brand-color); 27 | color: var(--color); 28 | } 29 | 30 | .hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label.hanko_dropdown { 31 | width: 100%; 32 | background: var(--brand-color); 33 | color: var(--color); 34 | } 35 | 36 | .hanko_paragraph:has(h2.hanko_headline) { 37 | color: var(--paragraph-inner-color); 38 | } 39 | -------------------------------------------------------------------------------- /src/core/services/gpt.ts: -------------------------------------------------------------------------------- 1 | import { ProjectPageView, ProjectView } from "./projects"; 2 | import OpenAI from "openai"; 3 | import Completion = OpenAI.Completion; 4 | import { supabase } from "../supabase"; 5 | 6 | export async function generateNewMermaidDiagramCode( 7 | project: ProjectView, 8 | page: { 9 | name: string; 10 | description: string; 11 | diagramType: string | null; 12 | }, 13 | prompt: string, 14 | ): Promise { 15 | const body = { 16 | projectName: project.name, 17 | projectDescription: project.description, 18 | pageName: page.name, 19 | diagramType: page.diagramType, 20 | prompt, 21 | }; 22 | return supabase.functions.invoke("generate-diagram", { body }).then(result => result.data); 23 | } 24 | 25 | export async function generateMermaidDiagramCode( 26 | project: ProjectView, 27 | page: ProjectPageView, 28 | prompt: string, 29 | ) { 30 | const body = { 31 | projectName: project.name, 32 | projectDescription: project.description, 33 | pageName: page.name, 34 | // TODO fix type 35 | diagramType: (page.content as any)["metadata"]["diagramType"], 36 | prompt, 37 | }; 38 | return fetch("/functions/v1/generate-diagram", { 39 | method: "POST", 40 | body: JSON.stringify(body), 41 | }).then((data) => data.json()); 42 | } 43 | -------------------------------------------------------------------------------- /src/core/utils/umami.ts: -------------------------------------------------------------------------------- 1 | declare const umami: Umami; 2 | 3 | // https://umami.is/docs/tracker-functions 4 | interface Umami { 5 | (event_value: string): void; 6 | 7 | trackEvent( 8 | event_value: string, 9 | event_type: string, 10 | url?: string, 11 | website_id?: string, 12 | ): void; 13 | 14 | trackView(url: string, referrer?: string, website_id?: string): void; 15 | } 16 | 17 | declare global { 18 | interface Window { 19 | umami: Umami; 20 | } 21 | } 22 | 23 | const isDev = import.meta.env.DEV; 24 | 25 | function getUmamiMock() { 26 | const umamiMock: Umami = () => void 0; 27 | 28 | if (isDev) { 29 | umamiMock.trackEvent = (event_value, event_type, url, website_id) => { 30 | console.groupCollapsed(`[DEV] Umami track event`); 31 | console.table([{ event_value, event_type, website_id, url }]); 32 | console.groupEnd(); 33 | }; 34 | umamiMock.trackView = (url, referrer, website_id) => { 35 | console.groupCollapsed(`[DEV] Umami track view`); 36 | console.table([{ url, referrer, website_id }]); 37 | console.groupEnd(); 38 | }; 39 | } else { 40 | umamiMock.trackEvent = () => void 0; 41 | umamiMock.trackView = () => void 0; 42 | } 43 | 44 | return umamiMock; 45 | } 46 | 47 | export function getUmami() { 48 | return window?.umami ?? getUmamiMock(); 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/SegmentedControl/SegmentedControl.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "@kobalte/core"; 2 | import * as styles from "./SegmentedControl.css"; 3 | import { splitProps } from "solid-js"; 4 | 5 | interface SegmentedControlItemProps 6 | extends Omit { 7 | value: T; 8 | } 9 | 10 | export function SegmentedControlItem(props: SegmentedControlItemProps) { 11 | // TODO: merge classes 12 | const [local, others] = splitProps(props, ["class"]); 13 | return ( 14 | // @ts-ignore 15 | 19 | ); 20 | } 21 | 22 | type TypedTabsRootProps = { 23 | value?: T; 24 | defaultValue?: T; 25 | onChange?: (value: T) => void; 26 | }; 27 | 28 | type SegmentedControlProps = Omit< 29 | Tabs.TabsRootProps, 30 | "orientation" | "value" | "defaultValue" | "onChange" 31 | > & 32 | TypedTabsRootProps; 33 | 34 | export function SegmentedControl(props: SegmentedControlProps) { 35 | return ( 36 | 42 | 43 | {props.children} 44 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorToolbar/DiagramActionToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@codeui/kit"; 2 | import { provideState } from "statebuilder"; 3 | import { EditorState } from "../editorState"; 4 | import { PreviewState } from "../previewState"; 5 | import { createControlledDialog } from "../../../../core/utils/controlledDialog"; 6 | import { ProjectEditorExportDiagramDialog } from "../ProjectEditorExportDialog/ProjectEditorExportDiagramDialog"; 7 | 8 | export function DiagramActionToolbar() { 9 | const editorState = provideState(EditorState); 10 | const previewState = provideState(PreviewState); 11 | 12 | const controlledDialog = createControlledDialog(); 13 | 14 | return ( 15 | 16 | previewState.openToExternalWindow()} 19 | loading={previewState.openToExternalWindow.loading} 20 | size={"sm"} 21 | class={"h-full"} 22 | theme={"tertiary"} 23 | > 24 | Open 25 | 26 | 29 | controlledDialog(ProjectEditorExportDiagramDialog, { 30 | projectPage: editorState.selectedPage()!, 31 | }) 32 | } 33 | size={"sm"} 34 | class={"h-full"} 35 | theme={"primary"} 36 | > 37 | Save 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from "solid-js/web"; 3 | 4 | import "./index.css"; 5 | import App from "./App"; 6 | import { Router } from "@solidjs/router"; 7 | import { StateProvider } from "statebuilder"; 8 | import { Suspense } from "solid-js"; 9 | 10 | const root = document.getElementById("root"); 11 | 12 | const isDev = import.meta.env.DEV; 13 | const enableAuthMock = import.meta.env.VITE_ENABLE_AUTH_MOCK; 14 | 15 | if (isDev && !(root instanceof HTMLElement)) { 16 | throw new Error( 17 | "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", 18 | ); 19 | } 20 | 21 | if (isDev && enableAuthMock) { 22 | Promise.all([import("msw/browser"), import("./mocks/handler")]) 23 | .then(([{ setupWorker }, { handlers }]) => { 24 | const worker = setupWorker(...handlers); 25 | if (import.meta.hot) { 26 | import.meta.hot.accept(() => { 27 | worker.resetHandlers(...handlers); 28 | }); 29 | } 30 | return worker.start(); 31 | }) 32 | .then(() => bootstrapApplication()); 33 | } else { 34 | bootstrapApplication(); 35 | } 36 | 37 | export function bootstrapApplication() { 38 | return render(() => { 39 | return ( 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | }, root!); 49 | } 50 | -------------------------------------------------------------------------------- /src/core/state/hanko.ts: -------------------------------------------------------------------------------- 1 | import { makePlugin } from "statebuilder"; 2 | import { 3 | Hanko, 4 | register, 5 | SessionDetail, 6 | User, 7 | } from "@teamhanko/hanko-elements"; 8 | import { MOCK_AUTH_CONTEXT_KEY } from "./constants"; 9 | 10 | export const hankoApi = import.meta.env.VITE_HANKO_API_URL; 11 | const hanko = new Hanko(hankoApi); 12 | 13 | interface WithHanko { 14 | hanko: Hanko; 15 | session: () => SessionDetail; 16 | 17 | getCurrentUser(): Promise; 18 | } 19 | 20 | export const withHanko = () => 21 | makePlugin( 22 | (_, context): WithHanko => { 23 | const enableMockAuth = context.metadata.get( 24 | MOCK_AUTH_CONTEXT_KEY, 25 | ) as boolean; 26 | 27 | context.hooks.onInit(() => { 28 | register(hankoApi, { enablePasskeys: !enableMockAuth }).then(); 29 | }); 30 | 31 | return { 32 | hanko, 33 | session() { 34 | return hanko.session.get(); 35 | }, 36 | getCurrentUser() { 37 | return hanko.user.getCurrent(); 38 | }, 39 | }; 40 | }, 41 | { 42 | name: "hanko", 43 | // TODO: statebuilder check dependencies is not working correctly 44 | // dependencies: ["hankoContext"], 45 | }, 46 | ); 47 | 48 | export const withHankoContext = (mock: boolean) => 49 | makePlugin( 50 | (_, context) => { 51 | // TODO: improve DX ? 52 | context.metadata.set(MOCK_AUTH_CONTEXT_KEY, mock); 53 | }, 54 | { name: "hankoContext", dependencies: [] }, 55 | ); 56 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/types/supabase.ts: -------------------------------------------------------------------------------- 1 | import { MergeDeep } from "type-fest"; 2 | import { Database as DatabaseGenerated, Json } from "./supabase.generated"; 3 | import { PostgrestError } from "@supabase/supabase-js"; 4 | 5 | export type Database = MergeDeep< 6 | DatabaseGenerated, 7 | { 8 | public: { 9 | Views: { 10 | project_page_view: { 11 | Row: { 12 | content: Json; 13 | created_at: string; 14 | description: string | null; 15 | id: string; 16 | name: string; 17 | project_id: string; 18 | type: string; 19 | user_id: string; 20 | owner: boolean; 21 | }; 22 | }; 23 | project_view: { 24 | created_at: string; 25 | description: string; 26 | id: string; 27 | name: string; 28 | user_id: string; 29 | owner: boolean; 30 | }; 31 | }; 32 | }; 33 | } 34 | >; 35 | 36 | export type Tables = 37 | Database["public"]["Tables"][T]["Row"]; 38 | 39 | export type Views = 40 | Database["public"]["Views"][T]["Row"]; 41 | 42 | export type Enums = 43 | Database["public"]["Enums"][T]; 44 | 45 | export type DbResult = T extends PromiseLike ? U : never; 46 | export type DbResultOk = T extends PromiseLike<{ data: infer U }> 47 | ? Exclude 48 | : never; 49 | 50 | export type DbResultErr = PostgrestError; 51 | -------------------------------------------------------------------------------- /src/ui/SplitView/SplitView.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, JSXElement, Show } from "solid-js"; 2 | import { gutterCol, gutterCol1, splitView } from "./SplitView.css"; 3 | import Split, { SplitInstance } from "split-grid"; 4 | 5 | interface SplitViewProps { 6 | mode: "left" | "right" | "both"; 7 | left: JSXElement; 8 | right: JSXElement; 9 | } 10 | 11 | export function SplitView(props: SplitViewProps) { 12 | let split: SplitInstance | null; 13 | createEffect(() => { 14 | const mode = props.mode; 15 | if (split) { 16 | split.destroy(true); 17 | } 18 | if (mode === "left" || mode === "right") { 19 | split = null; 20 | } else if (mode === "both") { 21 | split = Split({ 22 | columnGutters: [ 23 | { 24 | track: 1, 25 | element: document.querySelector(`.${gutterCol1}`)!, 26 | }, 27 | ], 28 | }); 29 | } 30 | }); 31 | 32 | return ( 33 | 38 | 39 | 40 | {props.left} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | {props.right} 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Auth/HankoProfile.css.ts: -------------------------------------------------------------------------------- 1 | import { style } from "@vanilla-extract/css"; 2 | import { themeTokens, themeVars } from "@codeui/kit"; 3 | 4 | import { hankoComponent } from "./hanko-base.css"; 5 | 6 | export const hankoProfile = style([ 7 | hankoComponent, 8 | { 9 | vars: { 10 | "--color": themeVars.foreground, 11 | "--color-shade-1": themeVars.accent9, 12 | "--color-shade-2": themeVars.accent6, 13 | 14 | "--brand-color": themeVars.brand, 15 | "--brand-color-shade-1": themeVars.brandAccentActive, 16 | "--brand-contrast-color": "white", 17 | 18 | "--background-color": themeVars.accent4, 19 | 20 | "--container-padding": "0", 21 | 22 | "--paragraph-inner-color": themeVars.accent10, 23 | "--paragraph-heading-color": themeVars.foreground, 24 | "--accordion-container-bg": themeVars.accent4, 25 | }, 26 | width: "100%", 27 | ":focus-visible": { 28 | outline: "none", 29 | }, 30 | }, 31 | { 32 | selectors: { 33 | "&::part(container)": { 34 | borderRadius: themeTokens.radii.lg, 35 | backgroundColor: "transparent", 36 | border: "none", 37 | maxWidth: "100%", 38 | width: "100%", 39 | }, 40 | "&::part(headline1)": { 41 | fontSize: themeTokens.fontSize.xl, 42 | }, 43 | "&::part(paragraph)": { 44 | marginBottom: themeTokens.spacing["6"], 45 | }, 46 | "&::part(input)": { 47 | vars: { 48 | "--item-height": "42px", 49 | }, 50 | }, 51 | "&::part(button)": { 52 | vars: { 53 | "--item-height": "42px", 54 | }, 55 | }, 56 | }, 57 | }, 58 | ]); 59 | -------------------------------------------------------------------------------- /src/components/DiagramEditor/DiagramEditor.tsx: -------------------------------------------------------------------------------- 1 | import { MermaidPreview } from "./MermaidPreview"; 2 | import { MermaidEditor } from "../Editor/MermaidEditor"; 3 | import { Ref } from "solid-js"; 4 | import { SplitView } from "../../ui/SplitView/SplitView"; 5 | 6 | interface DiagramEditorProps { 7 | previewMode: string; 8 | pageId: string; 9 | content: string; 10 | disabled?: boolean; 11 | diagramType: string; 12 | onValueChange: (value: string) => void; 13 | onSaveShortcut: () => void; 14 | ref?: Ref; 15 | } 16 | 17 | export function DiagramEditor(props: DiagramEditorProps) { 18 | const mode = () => { 19 | switch (props.previewMode) { 20 | case "editor": 21 | return "left"; 22 | case "preview": 23 | return "right"; 24 | case "editor-with-preview": 25 | return "both"; 26 | default: 27 | return "left"; 28 | } 29 | }; 30 | return ( 31 | 32 | 35 | 42 | 43 | } 44 | right={ 45 | 46 | 51 | 52 | } 53 | mode={mode()} 54 | /> 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/PageEditor/PageEditorPreview.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, createSignal, on, onCleanup } from "solid-js"; 2 | import { Editor, EditorOptions } from "@tiptap/core"; 3 | import { TIPTAP_PLUGINS } from "./tiptap-plugins"; 4 | 5 | export type BaseEditorOptions = Omit, "element">; 6 | 7 | export interface UseEditorOptions 8 | extends BaseEditorOptions { 9 | element: T; 10 | } 11 | 12 | export function createEditor( 13 | props: () => UseEditorOptions, 14 | ): () => Editor | undefined { 15 | const [signal, setSignal] = createSignal(); 16 | 17 | createEffect(() => { 18 | const instance = new Editor({ 19 | ...props(), 20 | }); 21 | 22 | onCleanup(() => { 23 | instance.destroy(); 24 | }); 25 | 26 | setSignal(instance); 27 | }); 28 | 29 | return signal; 30 | } 31 | 32 | interface PageEditorPreviewProps { 33 | content: string; 34 | } 35 | 36 | export const TIPTAP_ATTRIBUTE_CLASSES = 37 | "prose-lg prose-stone prose-headings:font-display font-default focus:outline-none max-w-full"; 38 | 39 | export function PageEditorPreview(props: PageEditorPreviewProps) { 40 | let ref!: HTMLDivElement; 41 | 42 | const editor = createEditor(() => ({ 43 | element: ref!, 44 | extensions: TIPTAP_PLUGINS, 45 | editable: false, // Currently it's only a preview 46 | autofocus: false, 47 | content: props.content, 48 | editorProps: { 49 | attributes: { 50 | class: TIPTAP_ATTRIBUTE_CLASSES, 51 | }, 52 | }, 53 | })); 54 | 55 | createEffect( 56 | on( 57 | () => props.content, 58 | (content) => editor()?.commands?.setContent(content), 59 | ), 60 | ); 61 | 62 | return ; 63 | } 64 | -------------------------------------------------------------------------------- /src/global.css.ts: -------------------------------------------------------------------------------- 1 | import { 2 | globalKeyframes, 3 | globalStyle, 4 | keyframes, 5 | style, 6 | } from "@vanilla-extract/css"; 7 | import { themeTokens, themeVars } from "@codeui/kit"; 8 | 9 | globalStyle("html", { 10 | backgroundColor: "#111", 11 | color: "#fff", 12 | }); 13 | 14 | globalStyle("button[data-cui=button]", { 15 | verticalAlign: "text-bottom", 16 | lineHeight: 1, 17 | }); 18 | 19 | globalStyle("button, role[button]", { 20 | cursor: "default", 21 | }); 22 | 23 | export const bgBrand = style({ 24 | backgroundColor: themeVars.brand, 25 | color: themeVars.foreground, 26 | }); 27 | 28 | globalStyle("::-webkit-scrollbar", { 29 | width: "18px", 30 | height: "18px", 31 | }); 32 | 33 | globalStyle("::-webkit-scrollbar-track", { 34 | backgroundColor: "transparent", 35 | }); 36 | 37 | globalStyle("::-webkit-scrollbar-corner", { 38 | backgroundColor: themeVars.accent6, 39 | borderRadius: themeTokens.radii.full, 40 | backgroundClip: "content-box", 41 | border: "6px solid transparent", 42 | }); 43 | 44 | globalStyle("::-webkit-scrollbar-thumb", { 45 | backgroundColor: themeVars.accent6, 46 | borderRadius: themeTokens.radii.full, 47 | border: "6px solid transparent", 48 | backgroundClip: "content-box", 49 | transition: "background-color .2s", 50 | }); 51 | 52 | globalStyle("::-webkit-scrollbar-thumb:hover", { 53 | backgroundColor: themeVars.accent8, 54 | }); 55 | 56 | const backdropFilter = keyframes({ 57 | "0%": { 58 | backdropFilter: "blur(0px) saturate(180%)", 59 | }, 60 | "100%": { 61 | backdropFilter: "blur(8px) saturate(180%)", 62 | }, 63 | }); 64 | 65 | // Add backdrop filter blur for modals 66 | 67 | globalStyle("div[data-panel-size]", { 68 | animation: `${backdropFilter} 250ms normal forwards ease-in-out`, 69 | }); 70 | -------------------------------------------------------------------------------- /src/components/PageEditor/PageEditor.tsx: -------------------------------------------------------------------------------- 1 | import { Ref } from "solid-js"; 2 | import { MarkdownEditor } from "../Editor/MarkdownEditor"; 3 | import { PageEditorPreview } from "./PageEditorPreview"; 4 | import { SplitView } from "../../ui/SplitView/SplitView"; 5 | 6 | interface DiagramEditorProps { 7 | previewMode: string; 8 | content: string; 9 | diagramType: string; 10 | onValueChange: (value: string) => void; 11 | onSaveShortcut: () => void; 12 | disabled?: boolean; 13 | ref?: Ref; 14 | } 15 | 16 | export function PageEditor(props: DiagramEditorProps) { 17 | const mode = () => { 18 | switch (props.previewMode) { 19 | case "editor": 20 | return "left"; 21 | case "preview": 22 | return "right"; 23 | case "editor-with-preview": 24 | return "both"; 25 | default: 26 | return "left"; 27 | } 28 | }; 29 | 30 | return ( 31 | 32 | 39 | 46 | 47 | } 48 | right={ 49 | 53 | 54 | 55 | } 56 | /> 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Auth/HankoAuth.css.ts: -------------------------------------------------------------------------------- 1 | import { createTheme, style } from "@vanilla-extract/css"; 2 | import { responsiveStyle, themeTokens } from "@codeui/kit"; 3 | import { hankoComponent, hankoVars } from "./hanko-base.css"; 4 | 5 | export const [hankoAuthTheme, hankoAuthThemeVars] = createTheme({ 6 | containerPaddingXs: "0px", 7 | containerPadding: hankoVars.containerPadding, 8 | }); 9 | 10 | export const hankoAuth = style([ 11 | hankoComponent, 12 | hankoAuthTheme, 13 | { 14 | vars: { 15 | "--container-padding": hankoAuthThemeVars.containerPadding, 16 | }, 17 | selectors: { 18 | "&::part(headline1)": responsiveStyle({ 19 | xs: { 20 | textAlign: "center", 21 | }, 22 | lg: { 23 | textAlign: "left", 24 | }, 25 | }), 26 | "&::part(paragraph)": responsiveStyle({ 27 | xs: { 28 | textAlign: "center", 29 | }, 30 | lg: { 31 | textAlign: "left", 32 | }, 33 | }), 34 | "&::part(form-item)": { 35 | minWidth: "100%", 36 | }, 37 | "&::part(input)": { 38 | minWidth: "100%", 39 | // backgroundColor: "rgba(10, 10, 10, .75)", 40 | }, 41 | "&::part(input passcode-input)": { 42 | minWidth: "100%", 43 | }, 44 | "&::part(container)": responsiveStyle({ 45 | xs: { 46 | vars: { 47 | "--container-padding": hankoAuthThemeVars.containerPaddingXs, 48 | }, 49 | border: "none", 50 | borderRadius: themeTokens.radii.lg, 51 | backgroundColor: "transparent", 52 | }, 53 | sm: { 54 | vars: { 55 | "--container-padding": hankoAuthThemeVars.containerPadding, 56 | }, 57 | }, 58 | }), 59 | }, 60 | }, 61 | ]); 62 | -------------------------------------------------------------------------------- /supabase/migrations/20231022073236_integrate_platform_limits.sql: -------------------------------------------------------------------------------- 1 | create table if not exists 2 | public.platform 3 | ( 4 | id uuid not null default gen_random_uuid() primary key, 5 | created_at timestamp with time zone not null default now(), 6 | name character varying not null default ''::character varying, 7 | max_project_row_per_user int not null default 5, 8 | max_project_page_per_user int not null default 25 9 | ) tablespace pg_default; 10 | 11 | create or replace function public.get_user_project_rows(user_id text) returns int as 12 | $$ 13 | select count(id) 14 | from public.project 15 | where public.project.user_id = get_user_project_rows.user_id 16 | $$ language sql stable; 17 | 18 | create or replace function public.get_user_project_page_rows(project_id uuid) returns int as 19 | $$ 20 | select count(id) 21 | from public.project_page 22 | where public.project_page.project_id = get_user_project_page_rows.project_id 23 | $$ language sql stable; 24 | 25 | create or replace function public.get_platform_limits() returns platform as 26 | $$ 27 | select * 28 | from public.platform 29 | LIMIT 1 30 | $$ language sql stable; 31 | 32 | CREATE POLICY "limit_rows_to_project_by_platform_limits" 33 | ON "public"."project" as restrictive 34 | FOR INSERT 35 | WITH CHECK ( 36 | public.get_user_project_rows(auth.user_id())::integer < (SELECT max_project_row_per_user 37 | FROM public.get_platform_limits()) 38 | ); 39 | 40 | CREATE POLICY "limit_rows_to_project_page_by_platform_limits" 41 | ON "public"."project_page" as restrictive 42 | FOR INSERT 43 | WITH CHECK ( 44 | public.get_user_project_page_rows(project_page.project_id::uuid)::integer < 45 | (SELECT max_project_page_per_user 46 | FROM public.get_platform_limits()) 47 | ); 48 | -------------------------------------------------------------------------------- /src/ui/DynamicSizedContainer/DynamicSizedContainer.tsx: -------------------------------------------------------------------------------- 1 | import { animate, AnimationControls } from "motion"; 2 | import { FlowProps, onCleanup, onMount } from "solid-js"; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 5 | interface DynamicSizedContainerProps {} 6 | 7 | function toPx(value: number) { 8 | return `${value}px`; 9 | } 10 | 11 | /** 12 | * Ported from CodeImage 13 | * https://github.com/riccardoperra/codeimage/blob/main/apps/codeimage/src/ui/DynamicSizedContainer/DynamicSizedContainer.tsx 14 | * @param props 15 | * @constructor 16 | */ 17 | export function DynamicSizedContainer( 18 | props: FlowProps, 19 | ) { 20 | let ref!: HTMLDivElement; 21 | let animation: AnimationControls | null = null; 22 | 23 | const triggerAnimation = (width: string, height: string) => { 24 | return animate( 25 | ref, 26 | { 27 | width, 28 | height, 29 | }, 30 | { duration: 0.2, easing: [0.4, 0, 0.2, 1] }, 31 | ); 32 | }; 33 | 34 | const recalculateSize = () => { 35 | if (animation && animation.playState === "running") { 36 | animation.stop(); 37 | animation = null; 38 | } 39 | const currentWidth = ref.offsetWidth; 40 | const currentHeight = ref.offsetHeight; 41 | ref.style.width = "auto"; 42 | ref.style.height = "auto"; 43 | const newWidth = toPx(ref.offsetWidth); 44 | const newHeight = toPx(ref.offsetHeight); 45 | ref.style.width = toPx(currentWidth); 46 | ref.style.height = toPx(currentHeight); 47 | setTimeout(() => { 48 | animation = triggerAnimation(newWidth, newHeight); 49 | }, 0); 50 | }; 51 | 52 | onMount(() => { 53 | if (!ref) return; 54 | recalculateSize(); 55 | const resizeObserver = new MutationObserver(recalculateSize); 56 | resizeObserver.observe(ref, { childList: true, subtree: true }); 57 | return onCleanup(() => resizeObserver.disconnect()); 58 | }); 59 | 60 | return {props.children}; 61 | } 62 | -------------------------------------------------------------------------------- /supabase/migrations/20231020190554_schema_init.sql: -------------------------------------------------------------------------------- 1 | create or replace function auth.user_id() returns text as 2 | $$ 3 | select nullif(current_setting('request.jwt.claims', true)::json ->> 'userId', '')::text; 4 | $$ language sql stable; 5 | 6 | create table 7 | public.project 8 | ( 9 | id uuid not null default gen_random_uuid(), 10 | created_at timestamp with time zone not null default now(), 11 | name character varying not null default ''::character varying, 12 | description text not null, 13 | user_id text not null default auth.user_id(), 14 | constraint project_pkey primary key (id), 15 | constraint project_name_key unique (name) 16 | ) tablespace pg_default; 17 | 18 | create table 19 | public.project_page 20 | ( 21 | id uuid not null default gen_random_uuid(), 22 | created_at timestamp with time zone not null default now(), 23 | project_id uuid not null, 24 | name character varying not null, 25 | description text null, 26 | content json not null, 27 | type text not null, 28 | user_id text not null default auth.user_id(), 29 | constraint project_page_pkey primary key (id), 30 | constraint project_page_project_id_fkey foreign key (project_id) references project (id) on delete cascade 31 | ) tablespace pg_default; 32 | 33 | CREATE POLICY "Allows all operations" ON public.project 34 | AS PERMISSIVE FOR ALL 35 | TO public 36 | USING ((auth.user_id() = user_id)) 37 | WITH CHECK ((auth.user_id() = user_id)); 38 | 39 | ALTER TABLE public.project ENABLE ROW LEVEL SECURITY; 40 | 41 | CREATE POLICY "Allows all operations" ON public.project_page 42 | AS PERMISSIVE FOR ALL 43 | TO public 44 | USING ((auth.user_id() = user_id)) 45 | WITH CHECK ((auth.user_id() = user_id)); 46 | 47 | ALTER TABLE public.project_page ENABLE ROW LEVEL SECURITY; 48 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorNoPagesContent/ProjectEditorNoPagesContent.tsx: -------------------------------------------------------------------------------- 1 | import { getOwner } from "solid-js"; 2 | import { createControlledDialog } from "../../../../core/utils/controlledDialog"; 3 | import { provideState } from "statebuilder"; 4 | import { EditorState } from "../editorState"; 5 | import { Button } from "@codeui/kit"; 6 | import { DocumentTextIcon } from "../../../../icons/DocumentTextIcon"; 7 | import { PresentationChart } from "../../../../icons/PresentationChart"; 8 | import { ProjectEditorNewDiagramDialog } from "../ProjectEditorNewPageDialog/ProjectEditorNewDiagramDialog"; 9 | import { ProjectView } from "../../../../core/services/projects"; 10 | 11 | interface ProjectEditorNoPagesContentProps { 12 | projectView: ProjectView; 13 | } 14 | 15 | export function ProjectEditorNoPagesContent( 16 | props: ProjectEditorNoPagesContentProps, 17 | ) { 18 | const owner = getOwner(); 19 | const controlledDialog = createControlledDialog(); 20 | const editorState = provideState(EditorState); 21 | 22 | return ( 23 | 24 | 25 | No pages found in this project. 26 | 27 | 28 | } 32 | onClick={() => { 33 | editorState.openNewPageDialog(owner!, props.projectView.id!); 34 | }} 35 | > 36 | New page 37 | 38 | } 42 | onClick={() => 43 | controlledDialog(ProjectEditorNewDiagramDialog, { 44 | onSave: (result) => editorState.actions.addNewPage(result), 45 | projectId: props.projectView.id!, 46 | }) 47 | } 48 | > 49 | New diagram 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/Editor/readonly-ranges.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EditorState, 3 | StateEffect, 4 | StateField, 5 | Transaction, 6 | } from "@codemirror/state"; 7 | import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; 8 | 9 | const addUnderline = StateEffect.define<{ from: number; to: number }>({ 10 | map: ({ from, to }, change) => ({ 11 | from: change.mapPos(from), 12 | to: change.mapPos(to), 13 | }), 14 | }); 15 | 16 | const disabledLineField = StateField.define({ 17 | create() { 18 | return Decoration.none; 19 | }, 20 | update(underlines, tr) { 21 | underlines = underlines.map(tr.changes); 22 | for (let e of tr.effects) 23 | if (e.is(addUnderline)) { 24 | underlines = underlines.update({ 25 | add: [underlineMark.range(e.value.from, e.value.to)], 26 | }); 27 | } 28 | return underlines; 29 | }, 30 | provide: (f) => EditorView.decorations.from(f), 31 | }); 32 | 33 | const underlineMark = Decoration.mark({ class: "cm-disabled-line" }); 34 | 35 | export function readOnlyTransactionFilter() { 36 | return EditorState.transactionFilter.of((tr) => { 37 | let readonlyRangeSet = tr.startState.field(disabledLineField, false); 38 | if ( 39 | readonlyRangeSet && 40 | tr.docChanged && 41 | !tr.annotation(Transaction.remote) 42 | ) { 43 | let block = false; 44 | tr.changes.iterChangedRanges((chFrom, chTo) => { 45 | readonlyRangeSet!.between(chFrom, chTo, (roFrom, roTo) => { 46 | if (chTo > roFrom && chFrom < roTo) block = true; 47 | }); 48 | }); 49 | if (block) return []; 50 | } 51 | return tr; 52 | }); 53 | } 54 | 55 | export function disabledLineSelection( 56 | view: EditorView, 57 | from: number, 58 | to: number, 59 | ) { 60 | let effects: StateEffect[] = [addUnderline.of({ from, to })]; 61 | if (!effects.length) return false; 62 | 63 | if (!view.state.field(disabledLineField, false)) 64 | effects.push(StateEffect.appendConfig.of([disabledLineField])); 65 | view.dispatch({ effects }); 66 | return true; 67 | } 68 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solidPlugin from "vite-plugin-solid"; 3 | import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; 4 | // import devtools from "solid-devtools/vite"; 5 | 6 | export default defineConfig((options) => ({ 7 | plugins: [ 8 | /* 9 | Uncomment the following line to enable solid-devtools. 10 | For more info see https://github.com/thetarnav/solid-devtools/tree/main/packages/extension#readme 11 | */ 12 | solidPlugin(), 13 | // devtools(), 14 | 15 | vanillaExtractPlugin({ 16 | esbuildOptions: { 17 | external: ["solid-js", "solid-js/web"], 18 | }, 19 | }), 20 | { 21 | name: "html-inject-umami", 22 | transformIndexHtml(html) { 23 | const websiteId = "840fbc8b-5b20-4f0d-8e55-c82814cfadf5"; 24 | const scriptSrc = 25 | "https://umami-production-af3e.up.railway.app/custom-events.js"; 26 | 27 | if (options.mode !== "production" || !websiteId || !scriptSrc) 28 | return html; 29 | 30 | // Auto-track is off since query param push a new page view and breaks the analytics 31 | // TODO: Find a better solution to handle query params 32 | return html.replace( 33 | "", 34 | ``, 35 | ); 36 | }, 37 | }, 38 | ], 39 | server: { 40 | port: 3000, 41 | cors: false, 42 | proxy: { 43 | "/functions": { 44 | target: "http://localhost:54321", 45 | }, 46 | "/rest": { 47 | target: "http://localhost:54321", 48 | }, 49 | "/realtime": { 50 | target: "ws://localhost:54321/realtime", 51 | rewrite: (path) => path.replace(/^\/realtime/, ""), 52 | ws: true, 53 | changeOrigin: true, 54 | }, 55 | }, 56 | }, 57 | build: { 58 | target: "esnext", 59 | }, 60 | optimizeDeps: { 61 | // Add both @codemirror/state and @codemirror/view to included deps to optimize 62 | include: ["@codemirror/state", "@codemirror/view"], 63 | }, 64 | })); 65 | -------------------------------------------------------------------------------- /src/core/state/platform.ts: -------------------------------------------------------------------------------- 1 | import { ɵdefineResource } from "statebuilder"; 2 | import { getPlatformConfiguration, Platform } from "../services/platform"; 3 | import { supabase } from "../supabase"; 4 | import { createEffect, createMemo, on } from "solid-js"; 5 | import { RealtimeChannel } from "@supabase/supabase-js"; 6 | import { withLogger } from "./plugins/withLogger"; 7 | 8 | export const PlatformState = ɵdefineResource(getPlatformConfiguration) 9 | .extend(withLogger({ name: "PlatformState" })) 10 | .extend((_, context) => { 11 | let subscription: RealtimeChannel | null; 12 | 13 | const id = createMemo(() => _()?.id); 14 | 15 | context.hooks.onInit(() => { 16 | createEffect( 17 | on([() => _.state, id], ([state, id]) => { 18 | if (state !== "ready" && !!subscription) { 19 | subscription.unsubscribe().then(); 20 | subscription = null; 21 | } 22 | if (state === "ready" && id) { 23 | _.logger.info("Platform configuration ready", _()); 24 | subscription = supabase.realtime 25 | .channel("platform") 26 | .on( 27 | "postgres_changes", 28 | { 29 | event: "UPDATE", 30 | schema: "public", 31 | table: "platform", 32 | filter: `id=eq.${id}`, 33 | }, 34 | (payload) => { 35 | _.logger.success( 36 | `Received UPDATE event for ${payload.new.id}. Updating configuration`, 37 | payload.new, 38 | ); 39 | _.set((previous) => ({ ...previous, ...payload.new })); 40 | }, 41 | ) 42 | .subscribe((status, error) => { 43 | if (status === "CHANNEL_ERROR") { 44 | _.logger.error(`${status}`, error); 45 | } else { 46 | _.logger.info(`Supabase channel state: ${status}`); 47 | } 48 | }, 10000); 49 | } 50 | }), 51 | ); 52 | }); 53 | context.hooks.onDestroy(() => subscription?.unsubscribe()); 54 | }); 55 | -------------------------------------------------------------------------------- /src/ui/ConfirmDialog/ConfirmDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogPanelContent, 5 | DialogPanelFooter, 6 | } from "@codeui/kit"; 7 | import { Accessor, JSXElement, mergeProps, VoidProps } from "solid-js"; 8 | import { ControlledDialogProps } from "../../core/utils/controlledDialog"; 9 | 10 | interface ConfirmDialogProps extends ControlledDialogProps { 11 | onConfirm: () => void; 12 | closeOnConfirm?: boolean; 13 | title: string; 14 | loading?: Accessor; 15 | message: JSXElement; 16 | actionType?: "primary" | "danger"; 17 | } 18 | 19 | export function ConfirmDialog( 20 | props: VoidProps, 21 | ): JSXElement { 22 | const propsWithDefault = mergeProps( 23 | { actionType: "primary", loading: false } as const, 24 | props, 25 | ); 26 | return ( 27 | 33 | 34 | {propsWithDefault.message} 35 | 36 | 37 | 38 | props.onOpenChange?.(false)} 44 | > 45 | Close 46 | 47 | 48 | { 59 | propsWithDefault.onConfirm(); 60 | if (props.closeOnConfirm) { 61 | propsWithDefault.onOpenChange(false); 62 | } 63 | }} 64 | > 65 | Confirm 66 | 67 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/core/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import { Database } from "../types/supabase"; 3 | import { cookieStorage } from "./utils/cookieStorage"; 4 | import { SessionDetail } from "@teamhanko/hanko-elements"; 5 | 6 | export const supabaseCookieName = "sb-token"; 7 | 8 | const supabaseUrl = import.meta.env.VITE_CLIENT_SUPABASE_URL; 9 | const supabaseKey = import.meta.env.VITE_CLIENT_SUPABASE_KEY; 10 | 11 | const initialToken = getSupabaseCookie(); 12 | const supabaseToken = initialToken ?? supabaseKey; 13 | 14 | export const supabase = createClient(supabaseUrl, supabaseKey, { 15 | auth: { 16 | storage: undefined, 17 | detectSessionInUrl: false, 18 | autoRefreshToken: false, 19 | persistSession: false, 20 | storageKey: undefined, 21 | }, 22 | // TODO: This is commented in order to have the original headers valuated 23 | // with the supabaseKey. 24 | // global: { 25 | // headers: initialToken ? { Authorization: `Bearer ${initialToken}` } : {}, 26 | // }, 27 | }); 28 | 29 | const originalHeaders = structuredClone(supabase["rest"].headers); 30 | 31 | export function patchSupabaseRestClient(accessToken: string | null) { 32 | supabase.functions.setAuth(accessToken ?? supabaseToken); 33 | if (accessToken) { 34 | supabase["rest"].headers = { 35 | ...supabase["rest"].headers, 36 | Authorization: `Bearer ${accessToken}`, 37 | }; 38 | } else { 39 | supabase["rest"].headers = originalHeaders; 40 | } 41 | } 42 | 43 | export function getSupabaseCookie() { 44 | return cookieStorage.getItem(supabaseCookieName, { path: "/", secure: true }); 45 | } 46 | 47 | export function syncSupabaseTokenFromHankoSession( 48 | accessToken: string | null, 49 | session: SessionDetail, 50 | ) { 51 | if (accessToken === null) { 52 | cookieStorage.removeItem(supabaseCookieName); 53 | } else { 54 | const currentDate = new Date(); 55 | const expirationDate = new Date( 56 | currentDate.getTime() + session.expirationSeconds * 1000, 57 | ); 58 | cookieStorage.setItem(supabaseCookieName, accessToken, { 59 | expires: expirationDate.getTime(), 60 | secure: true, 61 | path: "/", 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/components/PageEditor/tiptap-plugins.ts: -------------------------------------------------------------------------------- 1 | import { StarterKit } from "@tiptap/starter-kit"; 2 | import TiptapImage from "@tiptap/extension-image"; 3 | import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; 4 | import TiptapLink from "@tiptap/extension-link"; 5 | import { TaskList } from "@tiptap/extension-task-list"; 6 | import { TaskItem } from "@tiptap/extension-task-item"; 7 | import { Markdown } from "tiptap-markdown"; 8 | 9 | export const TIPTAP_PLUGINS = [ 10 | StarterKit.configure({ 11 | bulletList: { 12 | HTMLAttributes: { 13 | class: "list-disc list-outside leading-3 -mt-2", 14 | }, 15 | }, 16 | orderedList: { 17 | HTMLAttributes: { 18 | class: "list-decimal list-outside leading-3 -mt-2", 19 | }, 20 | }, 21 | listItem: { 22 | HTMLAttributes: { 23 | class: "leading-normal -mb-2", 24 | }, 25 | }, 26 | blockquote: { 27 | HTMLAttributes: { 28 | class: "border-l-4 border-stone-700", 29 | }, 30 | }, 31 | codeBlock: { 32 | HTMLAttributes: { 33 | class: 34 | "rounded-sm bg-neutral-800 p-5 font-mono font-medium text-neutral-200", 35 | }, 36 | }, 37 | code: { 38 | HTMLAttributes: { 39 | class: 40 | "rounded-md bg-neutral-800 px-2 py-1.5 font-mono font-medium text-neutral-300", 41 | spellcheck: "false", 42 | }, 43 | }, 44 | horizontalRule: false, 45 | dropcursor: { 46 | color: "#DBEAFE", 47 | width: 4, 48 | }, 49 | gapcursor: false, 50 | }), 51 | TiptapImage, 52 | HorizontalRule.configure({ 53 | HTMLAttributes: { 54 | class: "mt-4 mb-6 border-t border-neutral-300", 55 | }, 56 | }), 57 | TiptapLink.configure({ 58 | HTMLAttributes: { 59 | class: 60 | "text-blue-400 underline underline-offset-[3px] hover:blue-stone-600 transition-colors cursor-pointer", 61 | }, 62 | }), 63 | TaskList.configure({ 64 | HTMLAttributes: { 65 | class: "not-prose pl-2", 66 | }, 67 | }), 68 | TaskItem.configure({ 69 | HTMLAttributes: { 70 | class: "flex items-start my-4", 71 | }, 72 | nested: true, 73 | }), 74 | Markdown.configure({ 75 | transformCopiedText: true, 76 | transformPastedText: true, 77 | }), 78 | ]; 79 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/previewState.ts: -------------------------------------------------------------------------------- 1 | import { defineSignal } from "statebuilder"; 2 | import { withProxyCommands } from "statebuilder/commands"; 3 | import { withAsyncAction } from "statebuilder/asyncAction"; 4 | import { 5 | domToBlob, 6 | domToPng, 7 | downloadFile, 8 | exportMermaidPreview, 9 | openInNewPage, 10 | svgToFile, 11 | } from "../../../core/utils/export"; 12 | 13 | type Commands = { 14 | setRef: HTMLElement; 15 | }; 16 | 17 | export type GenericExportOptions = { fileName: string }; 18 | 19 | export type ExportOptions = ExportPngOptions | ExportSvgOptions; 20 | 21 | export interface ExportPngOptions extends GenericExportOptions { 22 | type: "png"; 23 | scale: number; 24 | showBackground: boolean; 25 | } 26 | 27 | export interface ExportSvgOptions extends GenericExportOptions { 28 | type: "svg"; 29 | } 30 | 31 | export const PreviewState = defineSignal(() => null) 32 | .extend(withProxyCommands({ devtools: { storeName: "ref" } })) 33 | .extend(withAsyncAction()) 34 | .extend((_) => { 35 | _.hold(_.commands.setRef, (ref) => _.set(() => ref)); 36 | }) 37 | .extend((_) => { 38 | const node = () => { 39 | const ref = _()!; 40 | return ref.firstChild as SVGElement; 41 | }; 42 | 43 | const exportAndSave = _.asyncAction((options: ExportOptions) => { 44 | switch (options.type) { 45 | case "png": { 46 | return exportMermaidPreview(node(), (element) => { 47 | const node = options.showBackground 48 | ? element 49 | : (element.firstChild as SVGElement); 50 | return domToPng(node, options).then(downloadFile); 51 | }); 52 | } 53 | case "svg": { 54 | return exportMermaidPreview(node(), (element) => { 55 | const svg = element.firstChild as SVGElement; 56 | return new Promise((r) => { 57 | downloadFile(svgToFile(svg, options)); 58 | r(true); 59 | }); 60 | }); 61 | } 62 | default: { 63 | throw new TypeError(`Type not valid`); 64 | } 65 | } 66 | }); 67 | 68 | const openToExternalWindow = _.asyncAction(() => 69 | domToBlob(node()).then(openInNewPage), 70 | ); 71 | 72 | return { 73 | openToExternalWindow, 74 | exportAndSave, 75 | }; 76 | }); 77 | -------------------------------------------------------------------------------- /supabase/functions/generate-diagram/index.ts: -------------------------------------------------------------------------------- 1 | // Follow this setup guide to integrate the Deno language server with your editor: 2 | // https://deno.land/manual/getting_started/setup_your_environment 3 | // This enables autocomplete, go to definition, etc. 4 | 5 | import { corsHeaders } from "../_shared/cors.ts"; 6 | 7 | export const generatePrompt = ( 8 | projectName: string, 9 | projectDescription: string, 10 | pageName: string, 11 | prompt: string, 12 | diagramType: string, 13 | ) => { 14 | return [ 15 | `You are generating the code for a system called ${projectName}. Description: ${projectDescription}. 16 | Mermaid DOES NOT support special characters like "\\n" or "\\s" or "\\b." 17 | Diagram description from the user: ${prompt}. 18 | Title should be: ${pageName} 19 | YOU MUST GENERATE only the code using Mermaid syntax for a ${diagramType} diagram. Only code, no explanation or introduction. 20 | 21 | `.trim(), 22 | ]; 23 | }; 24 | 25 | interface Request { 26 | projectName: string; 27 | projectDescription: string; 28 | pageName: string; 29 | prompt: string; 30 | sequenceDiagram: string; 31 | } 32 | 33 | Deno.serve(async (req) => { 34 | if (req.method === "OPTIONS") { 35 | return new Response("ok", { headers: corsHeaders }); 36 | } 37 | 38 | const token = Deno.env.get("OPENAI_TOKEN"); 39 | const { pageName, projectDescription, projectName, prompt, sequenceDiagram } = 40 | (await req.json()) as Request; 41 | 42 | const completionConfig = { 43 | model: "gpt-3.5-turbo-instruct", 44 | prompt: generatePrompt( 45 | projectName, 46 | projectDescription, 47 | pageName, 48 | prompt, 49 | sequenceDiagram, 50 | ), 51 | max_tokens: 1000, 52 | stream: false, 53 | }; 54 | 55 | return fetch("https://api.openai.com/v1/completions", { 56 | method: "POST", 57 | headers: { 58 | ...corsHeaders, 59 | Authorization: `Bearer ${token}`, 60 | "Content-Type": "application/json", 61 | }, 62 | body: JSON.stringify(completionConfig), 63 | }); 64 | }); 65 | 66 | // To invoke: 67 | // curl -i --location --request POST 'http://localhost:54321/functions/v1/' \ 68 | // --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \ 69 | // --header 'Content-Type: application/json' \ 70 | // --data '{"name":"Functions"}' 71 | -------------------------------------------------------------------------------- /src/components/DiagramEditor/MermaidPreview.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, createSignal, on, onMount, Ref, Show } from "solid-js"; 2 | 3 | import mermaid from "mermaid"; 4 | import panzoom, { PanZoom } from "panzoom"; 5 | 6 | interface MermaidPreviewProps { 7 | id: string; 8 | content: string; 9 | ref?: Ref; 10 | } 11 | 12 | export function MermaidPreview(props: MermaidPreviewProps) { 13 | const [errorMessage, setErrorMessage] = createSignal(null); 14 | const id = "id-1"; 15 | let element!: HTMLDivElement; 16 | let panzoomInstance: PanZoom; 17 | 18 | const render = async (content: string) => { 19 | if (panzoomInstance) { 20 | panzoomInstance.dispose(); 21 | } 22 | const result: Error | boolean = await mermaid 23 | .parse(content) 24 | .then((_) => _) 25 | .catch((e) => e); 26 | 27 | if (result === true) { 28 | setErrorMessage(null); 29 | const svgContainer = document.querySelector(`#${id}`)! as HTMLElement; 30 | svgContainer.textContent = content; 31 | svgContainer.removeAttribute("data-processed"); 32 | mermaid 33 | .run({ 34 | nodes: [svgContainer], 35 | }) 36 | .then(() => { 37 | queueMicrotask(() => { 38 | panzoomInstance = panzoom(svgContainer.firstChild as SVGElement, { 39 | autocenter: true, 40 | }); 41 | (svgContainer.firstChild as SVGElement).classList.add( 42 | "cursor-move", 43 | ); 44 | }); 45 | }); 46 | } else if (result instanceof Error) { 47 | setErrorMessage(result.message); 48 | } 49 | }; 50 | 51 | onMount(() => { 52 | mermaid.initialize({ startOnLoad: false, theme: "dark", darkMode: true }); 53 | createEffect( 54 | on( 55 | () => props.content, 56 | (content) => { 57 | render(content).then(); 58 | }, 59 | ), 60 | ); 61 | }); 62 | 63 | return ( 64 | 68 | 69 | {(errorMessage) => ( 70 | 75 | {errorMessage()} 76 | 77 | )} 78 | 79 | 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/components/Editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createCodeMirror, 3 | createEditorControlledValue, 4 | createEditorFocus, 5 | createEditorReadonly, 6 | createLazyCompartmentExtension, 7 | } from "solid-codemirror"; 8 | import { 9 | highlightActiveLine, 10 | highlightActiveLineGutter, 11 | KeyBinding, 12 | keymap, 13 | lineNumbers, 14 | } from "@codemirror/view"; 15 | import { VoidProps } from "solid-js"; 16 | import { theme } from "./theme"; 17 | import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; 18 | import { bracketMatching, indentOnInput } from "@codemirror/language"; 19 | import { autocompletion, closeBracketsKeymap } from "@codemirror/autocomplete"; 20 | 21 | interface JsonEditorProps { 22 | value: string; 23 | onValueChange: (value: string) => void; 24 | onSave: () => void; 25 | disabled?: boolean; 26 | } 27 | 28 | export function Editor(props: VoidProps) { 29 | const { ref, createExtension, editorView } = createCodeMirror({ 30 | onValueChange: props.onValueChange, 31 | }); 32 | 33 | createEditorControlledValue(editorView, () => props.value); 34 | 35 | createEditorReadonly(editorView, () => props.disabled ?? false); 36 | 37 | createEditorFocus(editorView, (focused) => { 38 | if (!focused) { 39 | props.onSave(); 40 | } 41 | }); 42 | 43 | const saveKeymap: KeyBinding = { 44 | key: "Ctrl-s", 45 | preventDefault: true, 46 | run: (editor) => { 47 | props.onSave(); 48 | return editor.hasFocus; 49 | }, 50 | }; 51 | 52 | createExtension([ 53 | lineNumbers(), 54 | highlightActiveLine(), 55 | highlightActiveLineGutter(), 56 | indentOnInput(), 57 | history(), 58 | bracketMatching(), 59 | autocompletion({ 60 | defaultKeymap: true, 61 | icons: true, 62 | aboveCursor: true, 63 | activateOnTyping: true, 64 | }), 65 | keymap.of([ 66 | ...closeBracketsKeymap, 67 | ...defaultKeymap, 68 | ...historyKeymap, 69 | saveKeymap, 70 | ]), 71 | ]); 72 | 73 | createLazyCompartmentExtension(() => { 74 | return Promise.all([ 75 | import("@codemirror/lang-javascript").then(({ javascript }) => [ 76 | javascript({ jsx: true, typescript: true }), 77 | ]), 78 | ]); 79 | }, editorView); 80 | 81 | createExtension(theme); 82 | 83 | return ( 84 | <> 85 | 93 | > 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/core/constants/diagrams.ts: -------------------------------------------------------------------------------- 1 | export const DIAGRAMS = { 2 | sequenceDiagram: { 3 | name: "Sequence diagram", 4 | example: 5 | "sequenceDiagram\n" + 6 | " Alice->>+John: Hello John, how are you?\n" + 7 | " Alice->>+John: John, can you hear me?\n" + 8 | " John-->>-Alice: Hi Alice, I can hear you!\n" + 9 | " John-->>-Alice: I feel great!", 10 | }, 11 | mindMap: { 12 | name: "Mind map", 13 | example: 14 | "mindmap\n" + 15 | " root((mindmap))\n" + 16 | " Origins\n" + 17 | " Long history\n" + 18 | " ::icon(fa fa-book)\n" + 19 | " Popularisation\n" + 20 | " British popular psychology author Tony Buzan\n" + 21 | " Research\n" + 22 | " On effectivnessand features\n" + 23 | " On Automatic creation\n" + 24 | " Uses\n" + 25 | " Creative techniques\n" + 26 | " Strategic planning\n" + 27 | " Argument mapping\n" + 28 | " Tools\n" + 29 | " Pen and paper\n" + 30 | " Mermaid", 31 | }, 32 | pieChart: { 33 | name: "Pie chart", 34 | example: 35 | "pie title Pets adopted by volunteers\n" + 36 | ' "Dogs" : 386\n' + 37 | ' "Cats" : 85\n' + 38 | ' "Rats" : 15', 39 | }, 40 | flowChart: { 41 | name: "Flow chart", 42 | example: 43 | "flowchart TD\n" + 44 | " A[Christmas] -->|Get money| B(Go shopping)\n" + 45 | " B --> C{Let me think}\n" + 46 | " C -->|One| D[Laptop]\n" + 47 | " C -->|Two| E[iPhone]\n" + 48 | " C -->|Three| F[fa:fa-car Car]", 49 | }, 50 | requirementDiagram: { 51 | name: "Requirement Diagram", 52 | example: 53 | "requirementDiagram\n" + 54 | "\n" + 55 | " requirement test_req {\n" + 56 | " id: 1\n" + 57 | " text: the test text.\n" + 58 | " risk: high\n" + 59 | " verifymethod: test\n" + 60 | " }\n" + 61 | "\n" + 62 | " element test_entity {\n" + 63 | " type: simulation\n" + 64 | " }\n" + 65 | "\n" + 66 | " test_entity - satisfies -> test_req", 67 | }, 68 | erDiagram: { 69 | name: "Er Diagram", 70 | example: 71 | "erDiagram\n" + 72 | " accTitle: My Entity Relationship Diagram\n" + 73 | " accDescr: My Entity Relationship Diagram Description\n" + 74 | "\n" + 75 | " CUSTOMER ||--o{ ORDER : places\n" + 76 | " ORDER ||--|{ LINE-ITEM : contains\n" + 77 | " CUSTOMER }|..|{ DELIVERY-ADDRESS : uses", 78 | }, 79 | }; 80 | -------------------------------------------------------------------------------- /public/hanko.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/ui/SegmentedControl/SegmentedControl.css.ts: -------------------------------------------------------------------------------- 1 | import { themeTokens, themeVars } from "@codeui/kit"; 2 | import { createTheme, style } from "@vanilla-extract/css"; 3 | 4 | export const [segmentedFieldTheme, segmentedFieldVars] = createTheme({ 5 | activeSegmentedBackgroundColor: themeVars.brandSecondaryAccentHover, 6 | segmentedTextColor: "white", 7 | activeSegmentedTextColor: "white", 8 | }); 9 | 10 | export const list = style({ 11 | display: "flex", 12 | gap: themeTokens.spacing["2"], 13 | position: "relative", 14 | width: "100%", 15 | }); 16 | 17 | export const wrapper = style([ 18 | segmentedFieldTheme, 19 | { 20 | alignItems: "stretch", 21 | width: "100%", 22 | height: "100%", 23 | }, 24 | { 25 | display: "flex", 26 | flexWrap: "nowrap", 27 | overflow: "visible", 28 | borderRadius: themeTokens.radii.md, 29 | cursor: "default", 30 | textAlign: "center", 31 | userSelect: "none", 32 | backgroundColor: themeVars.formAccent, 33 | position: "relative", 34 | padding: themeTokens.spacing["1"], 35 | }, 36 | ]); 37 | 38 | export const indicator = style([ 39 | { 40 | position: "absolute", 41 | height: "100%", 42 | transition: 43 | "width 250ms cubic-bezier(.2, 0, 0, 1), transform 250ms cubic-bezier(.2, 0, 0, 1)", 44 | backgroundColor: segmentedFieldVars.activeSegmentedBackgroundColor, 45 | content: "", 46 | boxShadow: themeTokens.boxShadow.md, 47 | borderRadius: themeTokens.radii.sm, 48 | }, 49 | ]); 50 | 51 | export const segment = style([ 52 | { 53 | height: "100%", 54 | position: "relative", 55 | display: "flex", 56 | alignItems: "center", 57 | justifyContent: "center", 58 | flexGrow: 1, 59 | fontSize: themeTokens.fontSize.sm, 60 | padding: `${themeTokens.spacing["0"]} ${themeTokens.spacing["2"]}`, 61 | color: segmentedFieldVars.segmentedTextColor, 62 | opacity: 0.65, 63 | zIndex: 1, 64 | fontWeight: themeTokens.fontWeight.medium, 65 | borderRadius: themeTokens.radii.sm, 66 | transition: 67 | "opacity .2s, background-color .2s, transform .2s, outline-color 150ms ease-in-out, outline-offset 150ms ease-in", 68 | outlineColor: `transparent`, 69 | outlineOffset: "0px", 70 | selectors: { 71 | "&:not(:disabled)": { 72 | cursor: "pointer", 73 | }, 74 | "&[data-selected]": { 75 | fontWeight: themeTokens.fontWeight.semibold, 76 | opacity: 1, 77 | color: segmentedFieldVars.activeSegmentedTextColor, 78 | }, 79 | }, 80 | ":focus-visible": { 81 | outlineOffset: "2px", 82 | outline: `2px solid ${themeVars.brand}`, 83 | }, 84 | }, 85 | ]); 86 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Component, lazy, onMount, Show, Suspense } from "solid-js"; 2 | import { RouteDataFuncArgs, useRoutes } from "@solidjs/router"; 3 | import { provideState } from "statebuilder"; 4 | import { AuthState } from "./core/state/auth"; 5 | import "./global.css"; 6 | import { PlatformState } from "./core/state/platform"; 7 | import { LoadingCircleWithBackdrop } from "./icons/LoadingCircle"; 8 | import { NotFound } from "./components/NotFound/NotFound"; 9 | import { Footer } from "./components/Footer/Footer"; 10 | import { getUmami } from "./core/utils/umami"; 11 | import { Auth } from "./components/Auth/Auth"; 12 | 13 | const App: Component = () => { 14 | document.documentElement.setAttribute("data-cui-theme", "dark"); 15 | 16 | const auth = provideState(AuthState); 17 | const platform = provideState(PlatformState); 18 | 19 | const authGuard = ( 20 | data: RouteDataFuncArgs, 21 | onLoggedIn: (data: RouteDataFuncArgs) => void, 22 | ) => (auth.loggedIn() ? onLoggedIn(data) : data.navigate("/login")); 23 | 24 | const Routes = useRoutes([ 25 | { 26 | path: "/", 27 | data: ({ navigate }) => { 28 | getUmami().trackView("/"); 29 | navigate("/projects"); 30 | }, 31 | }, 32 | { 33 | path: "/projects", 34 | data: (data) => authGuard(data, () => getUmami().trackView("/projects")), 35 | component: lazy(() => 36 | import("./components/Projects/Projects").then(({ Projects }) => ({ 37 | default: Projects, 38 | })), 39 | ), 40 | }, 41 | { 42 | path: "/projects/:id/editor", 43 | data: (data) => 44 | authGuard(data, ({ params }) => 45 | getUmami().trackView(`/projects/${data.params.id}`), 46 | ), 47 | component: lazy(() => 48 | import("./components/Projects/ProjectEditor/ProjectEditor").then( 49 | ({ ProjectEditorRoot }) => ({ default: ProjectEditorRoot }), 50 | ), 51 | ), 52 | }, 53 | { 54 | path: "/login", 55 | data: () => getUmami().trackView(`/login`), 56 | component: Auth, 57 | }, 58 | { 59 | path: "/not-found", 60 | component: NotFound, 61 | }, 62 | { 63 | path: "/*", 64 | data: ({ navigate }) => navigate("/projects"), 65 | }, 66 | ]); 67 | 68 | return ( 69 | 70 | }> 71 | 74 | 75 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default App; 83 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorHeader.tsx: -------------------------------------------------------------------------------- 1 | import { For, Show } from "solid-js"; 2 | import { Link, useNavigate } from "@solidjs/router"; 3 | import { ProjectView } from "../../../core/services/projects"; 4 | import { Button, IconButton } from "@codeui/kit"; 5 | import { ShareIcon } from "../../../icons/ShareIcon"; 6 | import { CurrentUserBadge } from "../../../ui/UserBadge/CurrentUserBadge"; 7 | import { LeftArrowIcon } from "../../../icons/LeftArrowIcon"; 8 | import { createBreakpoints } from "../../../core/utils/breakpoint"; 9 | import { provideState } from "statebuilder"; 10 | import { EditorState } from "./editorState"; 11 | import { MenuBars3Icon } from "../../../icons/MenuBars3Icon"; 12 | 13 | interface ProjectEditorHeaderProps { 14 | project: ProjectView; 15 | } 16 | 17 | type LinkItem = { path: string | null; label: string }; 18 | 19 | export function ProjectEditorHeader(props: ProjectEditorHeaderProps) { 20 | const breakpoints = createBreakpoints(); 21 | const links = () => 22 | [ 23 | breakpoints.sm ? { path: "/projects", label: "My projects" } : null, 24 | { path: null, label: props.project?.name }, 25 | ].filter((v): v is NonNullable => !!v); 26 | 27 | const navigate = useNavigate(); 28 | const editorState = provideState(EditorState); 29 | 30 | return ( 31 | 32 | 33 | editorState.actions.toggleSidebar()} 39 | > 40 | 41 | 42 | 43 | navigate("/")} 49 | > 50 | 51 | 52 | 53 | 54 | {(link, index) => ( 55 | <> 56 | {link.label}} when={link.path}> 57 | 58 | {link.label} 59 | 60 | 61 | / 62 | > 63 | )} 64 | 65 | 66 | 67 | {/*}*/} 69 | {/* size={"sm"}*/} 70 | {/* theme={"primary"}*/} 71 | {/*>*/} 72 | {/* Share*/} 73 | {/**/} 74 | 75 | 76 | 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /supabase/functions/hanko-auth/index.ts: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | import { corsHeaders } from "../_shared/cors.ts"; 3 | 4 | Deno.serve(async (req) => { 5 | console.log(req.headers.get("host")); 6 | if (req.method === "OPTIONS") { 7 | return new Response(JSON.stringify({ status: "ok" }), { 8 | headers: { ...corsHeaders, "Content-Type": "application/json" }, 9 | }); 10 | } 11 | const hankoToken = ((await req.json()) ?? {}).token as string | null; 12 | 13 | if (!hankoToken) { 14 | return new Response( 15 | JSON.stringify({ code: 401, message: "Unauthorized" }), 16 | { 17 | headers: { ...corsHeaders, "Content-Type": "application/json" }, 18 | status: 401, 19 | }, 20 | ); 21 | } 22 | 23 | const hankoApiUrl = Deno.env.get("HANKO_API_URL"); 24 | const supabaseToken = Deno.env.get("PRIVATE_KEY_SUPABASE") as string; 25 | 26 | const skipAuth = Deno.env.get("SKIP_AUTH") ?? false; 27 | if (skipAuth) { 28 | const jwt = jose.decodeJwt(hankoToken); 29 | console.log( 30 | `Bypassing authentication flow for user ${jwt.sub}. SKIP_AUTH=true`, 31 | ); 32 | const token = await buildMockToken(jwt); 33 | return buildSuccessResponse(token, jwt.exp!); 34 | } 35 | 36 | try { 37 | const JWKS = jose.createRemoteJWKSet( 38 | new URL(`${hankoApiUrl}/.well-known/jwks.json`), 39 | ); 40 | const data = await jose.jwtVerify(hankoToken, JWKS); 41 | const payload = { 42 | exp: data.payload.exp, 43 | userId: data.payload.sub, 44 | }; 45 | const token = await buildSupabaseToken(payload, payload.exp! , supabaseToken); 46 | return buildSuccessResponse(token, data.payload.exp!); 47 | } catch (e) { 48 | return new Response( 49 | JSON.stringify({ code: 401, message: (e as Error).message }), 50 | { 51 | headers: { ...corsHeaders, "Content-Type": "application/json" }, 52 | status: 401, 53 | }, 54 | ); 55 | } 56 | }); 57 | 58 | function buildSupabaseToken(payload: unknown, exp: number, secret: string) { 59 | return new jose.SignJWT(payload) 60 | .setExpirationTime(exp) 61 | .setProtectedHeader({ alg: "HS256" }) 62 | .sign(new TextEncoder().encode(secret)); 63 | } 64 | 65 | function buildMockToken(jwt: jose.JWTPayload) { 66 | const jwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; 67 | const payload = buildPayload(jwt.exp!, jwt.sub!); 68 | return buildSupabaseToken(payload, jwt.exp!, jwtSecret); 69 | } 70 | 71 | function buildPayload(exp: number, sub: string) { 72 | return { exp: exp, userId: sub }; 73 | } 74 | 75 | function buildSuccessResponse(token: string, exp: number) { 76 | return new Response( 77 | JSON.stringify({ access_token: token, expires_in: exp }), 78 | { 79 | headers: { 80 | ...corsHeaders, 81 | "Content-Type": "application/json", 82 | }, 83 | }, 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from "@solidjs/router"; 2 | import { createResource, Show, Suspense } from "solid-js"; 3 | import { getProject, ProjectView } from "../../../core/services/projects"; 4 | import { ProjectEditorHeader } from "./ProjectEditorHeader"; 5 | import { ProjectEditorSidebar } from "./ProjectEditorSidebar/ProjectEditorSidebar"; 6 | import { 7 | provideState, 8 | StateProvider, 9 | ɵWithResourceStorage, 10 | } from "statebuilder"; 11 | import { EditorState } from "./editorState"; 12 | import { ProjectEditorContent } from "./ProjectEditorContent/ProjectEditorContent"; 13 | import { ProjectEditorToolbar } from "./ProjectEditorToolbar/ProjectEditorToolbar"; 14 | import * as styles from "./ProjectEditor.css"; 15 | import { LoadingCircleWithBackdrop } from "../../../icons/LoadingCircle"; 16 | import { ProjectEditorNoPagesContent } from "./ProjectEditorNoPagesContent/ProjectEditorNoPagesContent"; 17 | 18 | export function ProjectEditorRoot() { 19 | return ( 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | function ProjectEditor() { 27 | const params = useParams<{ id: string }>(); 28 | const editorState = provideState(EditorState); 29 | const navigate = useNavigate(); 30 | 31 | const [projectView] = createResource( 32 | () => params.id, 33 | async (id) => { 34 | const project = await getProject(id); 35 | if (!project) { 36 | navigate("/not-found"); 37 | } 38 | return project; 39 | }, 40 | { 41 | storage: ɵWithResourceStorage( 42 | Object.assign(() => editorState.get.projectView, { 43 | set: (v: ProjectView | null) => editorState.actions.setProjectView(v), 44 | }), 45 | ), 46 | initialValue: null, 47 | }, 48 | ); 49 | 50 | return ( 51 | }> 52 | 53 | {(projectView) => ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | 63 | } 64 | when={editorState.selectedPage()} 65 | > 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | )} 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/Editor/MarkdownEditor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createCodeMirror, 3 | createEditorControlledValue, 4 | createEditorFocus, 5 | createEditorReadonly, 6 | createLazyCompartmentExtension, 7 | } from "solid-codemirror"; 8 | import { 9 | EditorView, 10 | highlightActiveLine, 11 | highlightActiveLineGutter, 12 | KeyBinding, 13 | keymap, 14 | lineNumbers, 15 | } from "@codemirror/view"; 16 | import { VoidProps } from "solid-js"; 17 | import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; 18 | import { bracketMatching, indentOnInput } from "@codemirror/language"; 19 | import { autocompletion, closeBracketsKeymap } from "@codemirror/autocomplete"; 20 | import { theme } from "./theme"; 21 | 22 | interface JsonEditorProps { 23 | value: string; 24 | onValueChange: (value: string) => void; 25 | onSave: () => void; 26 | disabled?: boolean; 27 | type: string; 28 | } 29 | 30 | export function MarkdownEditor(props: VoidProps) { 31 | const { ref, createExtension, editorView } = createCodeMirror({ 32 | onValueChange: props.onValueChange, 33 | }); 34 | 35 | createEditorControlledValue(editorView, () => props.value); 36 | 37 | createEditorReadonly(editorView, () => props.disabled ?? false); 38 | 39 | createEditorFocus(editorView, (focused) => { 40 | if (!focused) { 41 | props.onSave(); 42 | } 43 | }); 44 | 45 | const saveKeymap: KeyBinding = { 46 | key: "Ctrl-s", 47 | preventDefault: true, 48 | run: (editor) => { 49 | props.onSave(); 50 | return editor.hasFocus; 51 | }, 52 | }; 53 | 54 | createExtension([ 55 | lineNumbers(), 56 | highlightActiveLine(), 57 | highlightActiveLineGutter(), 58 | indentOnInput(), 59 | history(), 60 | bracketMatching(), 61 | autocompletion({ 62 | defaultKeymap: true, 63 | icons: true, 64 | aboveCursor: true, 65 | activateOnTyping: true, 66 | }), 67 | keymap.of([ 68 | ...closeBracketsKeymap, 69 | ...defaultKeymap, 70 | ...historyKeymap, 71 | saveKeymap, 72 | ]), 73 | ]); 74 | 75 | createLazyCompartmentExtension(() => { 76 | return Promise.all([ 77 | import("@codemirror/lang-markdown").then(({ markdown }) => markdown()), 78 | ]); 79 | }, editorView); 80 | 81 | createExtension(theme); 82 | createExtension(() => 83 | EditorView.theme({ 84 | "&": { 85 | "min-width": "fit-content", 86 | "max-width": "100%", 87 | }, 88 | ".cm-scroller": { 89 | overflow: "hidden", 90 | }, 91 | }), 92 | ); 93 | 94 | return ( 95 | <> 96 | 104 | > 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/core/utils/export.ts: -------------------------------------------------------------------------------- 1 | import { createUniqueId } from "solid-js"; 2 | 3 | export type GenericExportOptions = { fileName: string }; 4 | 5 | export type ExportOptions = ExportPngOptions | ExportSvgOptions; 6 | 7 | export interface ExportPngOptions extends GenericExportOptions { 8 | type: "png"; 9 | scale: number; 10 | showBackground: boolean; 11 | } 12 | 13 | export interface ExportSvgOptions extends GenericExportOptions { 14 | type: "svg"; 15 | } 16 | 17 | export const exportMermaidPreview = ( 18 | svg: SVGElement, 19 | exportFn: (node: HTMLElement) => Promise, 20 | ) => { 21 | const uuid = createUniqueId(); 22 | const id = `export-preview-${uuid}`; 23 | const preview = document.createElement("div"); 24 | preview.setAttribute("id", id); 25 | // TODO: should be customizable? 26 | preview.classList.add("bg-neutral-800"); 27 | preview.style.position = "absolute"; 28 | preview.style.left = "0px"; 29 | preview.style.top = "0px"; 30 | preview.style.zIndex = "-1"; 31 | preview.style.width = "fit-content"; 32 | preview.style.height = "fit-content"; 33 | const clonedSvg = svg.cloneNode(true) as SVGElement; 34 | clonedSvg.style.removeProperty("max-width"); 35 | clonedSvg.style.removeProperty("transform-origin"); 36 | clonedSvg.style.removeProperty("transform"); 37 | preview.appendChild(clonedSvg); 38 | document.body.appendChild(preview); 39 | return exportFn(preview).finally(() => preview.remove()); 40 | }; 41 | 42 | export const downloadFile = (file: File) => { 43 | const link = document.createElement("a"); 44 | link.download = file.name; 45 | link.href = URL.createObjectURL(file); 46 | link.click(); 47 | link.remove(); 48 | }; 49 | 50 | export const openInNewPage = (data: Blob | MediaSource) => { 51 | const link = document.createElement("a"); 52 | link.target = "_blank"; 53 | link.href = URL.createObjectURL(data); 54 | link.click(); 55 | link.remove(); 56 | }; 57 | 58 | export const svgToFile = (svg: SVGElement, options: ExportSvgOptions) => { 59 | return new File([svg.outerHTML], options.fileName, { 60 | type: "image/svg+xml", 61 | }); 62 | }; 63 | 64 | export const domToPng = (node: Element, options: ExportPngOptions) => { 65 | return import("modern-screenshot").then((m) => 66 | m 67 | .domToBlob(node, { 68 | features: { fixSvgXmlDecode: true }, 69 | font: {}, 70 | type: "image/png", 71 | scale: options.scale ?? 6, 72 | style: { padding: "0px", margin: "0px" }, 73 | }) 74 | .then( 75 | (data) => 76 | new File([data], `${options.fileName}.png`, { 77 | type: "image/png", 78 | }), 79 | ), 80 | ); 81 | }; 82 | 83 | export const domToBlob = (node: SVGElement) => 84 | import("modern-screenshot").then((m) => 85 | exportMermaidPreview(node, (svg) => 86 | m.domToBlob(svg, { 87 | scale: 6, 88 | debug: true, 89 | style: { padding: "0px", margin: "0px" }, 90 | }), 91 | ), 92 | ); 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-template-solid", 3 | "version": "0.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "vite", 7 | "dev": "vite --host", 8 | "build": "vite build", 9 | "serve": "vite preview", 10 | "gen:types": "supabase gen types typescript --project-id \"byxdpolwzavnqnqtyqbs\" --schema public > src/types/supabase.generated.ts", 11 | "gen:types:local": "supabase gen types typescript --local --schema public > src/types/supabase.generated.ts", 12 | "supabase:serve:functions": "supabase start && supabase functions serve --env-file=./supabase/.env.local", 13 | "typecheck": "tsc --noEmit" 14 | }, 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@vanilla-extract/vite-plugin": "^3.9.0", 18 | "autoprefixer": "^10.4.16", 19 | "msw": "2.0.1", 20 | "postcss": "^8.4.31", 21 | "prettier": "^3.0.3", 22 | "solid-devtools": "^0.27.7", 23 | "supabase": "^1.106.1", 24 | "tailwindcss": "^3.3.5", 25 | "typescript": "^5.2.2", 26 | "vite": "^4.5.0", 27 | "vite-plugin-solid": "^2.7.2" 28 | }, 29 | "dependencies": { 30 | "@codemirror/autocomplete": "^6.10.2", 31 | "@codemirror/commands": "^6.3.0", 32 | "@codemirror/lang-javascript": "^6.2.1", 33 | "@codemirror/lang-json": "^6.0.1", 34 | "@codemirror/lang-markdown": "^6.2.2", 35 | "@codemirror/language": "^6.9.2", 36 | "@codemirror/lint": "^6.4.2", 37 | "@codemirror/state": "^6.3.1", 38 | "@codemirror/view": "^6.21.4", 39 | "@codeui/kit": "^0.0.30", 40 | "@formatjs/intl-relativetimeformat": "^11.2.7", 41 | "@kobalte/core": "^0.11.2", 42 | "@lezer/highlight": "^1.1.6", 43 | "@solid-primitives/date": "^2.0.18", 44 | "@solid-primitives/media": "^2.2.5", 45 | "@solid-primitives/storage": "^2.1.1", 46 | "@solidjs/router": "^0.8.3", 47 | "@supabase/supabase-js": "^2.38.4", 48 | "@tailwindcss/typography": "^0.5.10", 49 | "@teamhanko/hanko-elements": "^0.9.0", 50 | "@tiptap/core": "^2.1.12", 51 | "@tiptap/extension-horizontal-rule": "^2.1.12", 52 | "@tiptap/extension-image": "^2.1.12", 53 | "@tiptap/extension-link": "^2.1.12", 54 | "@tiptap/extension-task-item": "^2.1.12", 55 | "@tiptap/extension-task-list": "^2.1.12", 56 | "@tiptap/extension-underline": "^2.1.12", 57 | "@tiptap/pm": "^2.1.12", 58 | "@tiptap/starter-kit": "^2.1.12", 59 | "@vanilla-extract/css": "^1.13.0", 60 | "@vanilla-extract/recipes": "^0.5.0", 61 | "codemirror-lang-mermaid": "^0.5.0", 62 | "idb-keyval": "^6.2.1", 63 | "mermaid": "^10.6.0", 64 | "modern-screenshot": "^4.4.33", 65 | "motion": "^10.16.4", 66 | "openai": "^4.14.1", 67 | "panzoom": "^9.4.3", 68 | "rxjs": "^7.8.1", 69 | "solid-codemirror": "^2.3.0", 70 | "solid-js": "^1.8.4", 71 | "split-grid": "^1.0.11", 72 | "statebuilder": "0.0.0-20231028092513", 73 | "tiptap-markdown": "^0.8.2", 74 | "type-fest": "^4.6.0" 75 | }, 76 | "msw": { 77 | "workerDirectory": "public" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/components/Auth/Auth.css.ts: -------------------------------------------------------------------------------- 1 | import { keyframes, style } from "@vanilla-extract/css"; 2 | import { responsiveStyle, themeTokens, themeVars } from "@codeui/kit"; 3 | 4 | export const container = style({}); 5 | 6 | export const gradientBg = style({ 7 | position: "absolute", 8 | }); 9 | 10 | export const inner = style([ 11 | { 12 | position: "relative", 13 | maxWidth: "960px", 14 | width: "100%", 15 | padding: "20px", 16 | display: "grid", 17 | gap: "20px", 18 | justifyItems: "center", 19 | borderRadius: "20px", 20 | opacity: "1", 21 | animation: 22 | "1s cubic-bezier(0.075, 0.82, 0.165, 1) 0s 1 normal forwards running jkLqKc", 23 | }, 24 | responsiveStyle({ 25 | xs: { 26 | display: "grid", 27 | backgroundColor: "transparent", 28 | }, 29 | lg: { 30 | display: "grid", 31 | // background: "rgba(14, 5, 29, 0.3)", 32 | background: themeVars.accent1, 33 | backdropFilter: "blur(16px) saturate(180%)", 34 | // boxShadow: 35 | // "rgba(101, 39, 153, 0.2) 0px 30px 60px, rgba(255, 255, 255, 0.3) 0px 0px 0px 0.5px inset", 36 | boxShadow: "rgba(255, 255, 255, 0.3) 0px 0px 0px 0.5px inset", 37 | gridTemplateColumns: "360px auto", 38 | height: "600px", 39 | }, 40 | }), 41 | ]); 42 | 43 | export const authContainer = style([ 44 | responsiveStyle({ 45 | xs: { 46 | padding: 0, 47 | }, 48 | lg: { 49 | padding: themeTokens.spacing["6"], 50 | }, 51 | }), 52 | ]); 53 | 54 | export const backdrop = style({ 55 | display: "flex", 56 | position: "fixed", 57 | top: "0px", 58 | left: "0px", 59 | width: "100vw", 60 | height: "100vh", 61 | backgroundColor: "rgba(0, 0, 0, 0.2)", 62 | WebkitBoxPack: "center", 63 | justifyContent: "center", 64 | WebkitBoxAlign: "center", 65 | alignItems: "center", 66 | zIndex: "10", 67 | padding: "0px 20px", 68 | animation: "1s ease 0s 1 normal forwards running bDTdTe", 69 | }); 70 | 71 | export const leftContainer = style( 72 | responsiveStyle({ 73 | xs: { 74 | width: "100%", 75 | paddingBottom: themeTokens.spacing["6"], 76 | borderBottom: `1px solid ${themeVars.separator}`, 77 | borderRadius: 0, 78 | position: "relative", 79 | overflow: "hidden", 80 | display: "flex", 81 | justifyContent: "center", 82 | }, 83 | lg: { 84 | width: "360px", 85 | background: themeVars.brandSoftAccentHover, 86 | borderRadius: themeTokens.radii.lg, 87 | // background: themeVars.accent2, 88 | // background: "rgba(31, 31, 71, 0.6)",} 89 | }, 90 | }), 91 | ); 92 | 93 | const float = keyframes({ 94 | "0%": { 95 | transform: "scale(2) translateY(0px)", 96 | }, 97 | "50%": { 98 | transform: "scale(2) translateY(10px)", 99 | }, 100 | "100%": { 101 | transform: "scale(2) translateY(0px)", 102 | }, 103 | }); 104 | 105 | export const leftContainerImage2 = style({ 106 | transform: `scale(2)`, 107 | animation: `${float} 6s ease-in-out infinite`, 108 | }); 109 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorNewPageDialog/ProjectEditorNewPageDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createProjectPageText, 3 | ProjectPageView, 4 | } from "../../../../core/services/projects"; 5 | import { createStore, unwrap } from "solid-js/store"; 6 | import { 7 | Button, 8 | Dialog, 9 | DialogPanelContent, 10 | DialogPanelFooter, 11 | TextField, 12 | } from "@codeui/kit"; 13 | import { makeAsyncAction } from "statebuilder/asyncAction"; 14 | import { ControlledDialogProps } from "../../../../core/utils/controlledDialog"; 15 | import { createSignal } from "solid-js"; 16 | 17 | interface ProjectEditorPageSettingsDialogProps extends ControlledDialogProps { 18 | onSave: (projectPageView: ProjectPageView) => void; 19 | projectId: string; 20 | } 21 | 22 | interface Form { 23 | name: string; 24 | } 25 | 26 | export function ProjectEditorNewPageDialog( 27 | props: ProjectEditorPageSettingsDialogProps, 28 | ) { 29 | const [submitted, setSubmitted] = createSignal(false); 30 | const [form, setForm] = createStore({ 31 | name: "", 32 | }); 33 | 34 | const formValid = () => !!form.name; 35 | 36 | const onSave = (projectPageView: ProjectPageView) => { 37 | props.onOpenChange(false); 38 | props.onSave(projectPageView); 39 | }; 40 | 41 | const saveAction = makeAsyncAction((data: Form) => 42 | createProjectPageText(props.projectId, { 43 | name: data.name, 44 | content: "", 45 | }) 46 | .then((result) => onSave(result.data!)) 47 | .catch(() => { 48 | // TODO add error toast 49 | alert("Error"); 50 | }), 51 | ); 52 | 53 | const validations = { 54 | name: { 55 | state: () => (submitted() && !form.name ? "invalid" : undefined), 56 | errorMessage: () => "The field is required", 57 | }, 58 | }; 59 | 60 | return ( 61 | 67 | 68 | 69 | setForm("name", value)} 78 | /> 79 | 80 | 81 | 82 | 83 | props.onOpenChange(false)} 87 | > 88 | Cancel 89 | 90 | { 94 | setSubmitted(true); 95 | if (formValid()) { 96 | saveAction(unwrap(form)); 97 | } 98 | }} 99 | > 100 | Save 101 | 102 | 103 | 104 | 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectCard/ProjectCard.tsx: -------------------------------------------------------------------------------- 1 | import { deleteProject, Project } from "../../../core/services/projects"; 2 | import { Link } from "@solidjs/router"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuPortal, 8 | DropdownMenuTrigger, 9 | IconButton, 10 | } from "@codeui/kit"; 11 | import { As } from "@kobalte/core"; 12 | import { EllipsisIcon } from "../../../icons/EllipsisIcon"; 13 | import { createControlledDialog } from "../../../core/utils/controlledDialog"; 14 | import { ConfirmDialog } from "../../../ui/ConfirmDialog/ConfirmDialog"; 15 | import { createSignal } from "solid-js"; 16 | import { ProjectEditSettingsDialog } from "../ProjectEditSettingsDialog/ProjectEditSettingsDialog"; 17 | import { createTimeAgo } from "@solid-primitives/date"; 18 | 19 | interface ProjectCardProps { 20 | project: Project; 21 | onDelete: (project: Project) => void; 22 | onEdit: (project: Project) => void; 23 | } 24 | 25 | export function ProjectCard(props: ProjectCardProps) { 26 | const controlledDialog = createControlledDialog(); 27 | 28 | const onEdit = () => 29 | controlledDialog(ProjectEditSettingsDialog, { 30 | onSave: props.onEdit, 31 | project: props.project, 32 | }); 33 | 34 | const onDelete = () => { 35 | controlledDialog(ConfirmDialog, (openChange) => { 36 | const [loading, setLoading] = createSignal(false); 37 | return { 38 | title: "Delete project", 39 | message: "The action is not reversible.", 40 | onConfirm: () => { 41 | setLoading(true); 42 | deleteProject(props.project.id) 43 | .then(() => setLoading(false)) 44 | .then(() => props.onDelete(props.project)) 45 | .finally(() => openChange(false)); 46 | }, 47 | closeOnConfirm: false, 48 | loading: loading, 49 | actionType: "danger" as const, 50 | }; 51 | }); 52 | }; 53 | 54 | const [createdAt] = createTimeAgo(() => props.project.created_at); 55 | 56 | return ( 57 | 63 | 64 | 65 | 66 | {props.project.name} 67 | 68 | Created {createdAt()} 69 | 70 | 71 | 72 | 73 | 79 | 80 | 81 | 82 | 83 | 84 | Edit 85 | Delete 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/mocks/data/hanko.ts: -------------------------------------------------------------------------------- 1 | import { DefaultBodyType, HttpResponse, StrictResponse } from "msw"; 2 | import { parseJwt } from "./access-token"; 3 | import { logger } from "./log"; 4 | import type { 5 | Config as HankoConfigResponse, 6 | Email as HankoEmailResponse, 7 | Passcode as HankoPasscodeResponse, 8 | UserInfo as HankoUserInfoResponse, 9 | } from "@teamhanko/hanko-elements"; 10 | 11 | export type { HankoEmailResponse, HankoUserInfoResponse, HankoConfigResponse }; 12 | 13 | interface UserInfo { 14 | id: string; 15 | email: string; 16 | emailId: string; 17 | password: string; 18 | passcode: string; 19 | } 20 | 21 | export const hankoUsers = { 22 | user1: { 23 | id: "311b0b77-41c1-4750-a316-0b4c9e7cb1b3", 24 | email: "user1@example.com", 25 | password: "password", 26 | emailId: crypto.randomUUID(), 27 | passcode: "123456", 28 | }, 29 | user2: { 30 | id: "a405e378-066d-45f4-88b5-55d375064e4", 31 | email: "user2@example.com", 32 | password: "password", 33 | emailId: crypto.randomUUID(), 34 | passcode: "123456", 35 | }, 36 | } as Record; 37 | 38 | export function findUserById(id: string) { 39 | return Object.values(hankoUsers).find((user) => user.id === id); 40 | } 41 | 42 | export function findUserByEmail(email: string) { 43 | return Object.values(hankoUsers).find((hanko) => hanko.email === email); 44 | } 45 | 46 | export function buildUser(user: UserInfo | null | undefined) { 47 | if (!user) { 48 | return null; 49 | } 50 | return { 51 | id: user.id, 52 | email: user.email, 53 | webauthn_credentials: null, 54 | updated_at: "2023-10-09T18:24:56.190105Z", 55 | created_at: "2023-10-09T18:24:56.189997Z", 56 | }; 57 | } 58 | 59 | export interface HankoNotFoundResponse { 60 | code: 404; 61 | message: "Not Found"; 62 | } 63 | 64 | export interface HankoUnauthorizedResponse { 65 | code: 401; 66 | message: "Unauthorized"; 67 | } 68 | 69 | export interface HankoInitializePasscodeChallengeResponse 70 | extends HankoPasscodeResponse { 71 | created_at: string; 72 | } 73 | 74 | export function buildHankoNotFoundResponse(): StrictResponse { 75 | return HttpResponse.json( 76 | { code: 404, message: "Not Found" }, 77 | { status: 404 }, 78 | ); 79 | } 80 | 81 | export function buildHankoNotAuthorizedResponse(): StrictResponse { 82 | return HttpResponse.json( 83 | { code: 401, message: "Unauthorized" }, 84 | { status: 401 }, 85 | ); 86 | } 87 | 88 | export function getUserByCookies(cookies: Record) { 89 | const hankoJwt = cookies["hanko"] as string; 90 | logger.warn("[MSW/Hanko] Retrieving current user by cookies"); 91 | if (!hankoJwt) { 92 | logger.error("[MSW/Hanko] Cannot parse user from cookies"); 93 | return null; 94 | } 95 | const parsedJwt = parseJwt(hankoJwt); 96 | logger.info(`[MSW/Hanko] Sub: ${parsedJwt.sub}`); 97 | return findUserById(parsedJwt.sub); 98 | } 99 | 100 | export function buildResponseForToken( 101 | data: T, 102 | token: string, 103 | ) { 104 | return HttpResponse.json(data, { 105 | headers: { 106 | "X-Auth-Token": token, 107 | "X-Session-Lifetime": "3600", 108 | }, 109 | }); 110 | } 111 | 112 | export interface HankoCurrentUserResponse { 113 | id: string; 114 | } 115 | -------------------------------------------------------------------------------- /src/components/Projects/NewProjectDialog/NewProjectDialog.tsx: -------------------------------------------------------------------------------- 1 | import { createStore, unwrap } from "solid-js/store"; 2 | import { 3 | Button, 4 | Dialog, 5 | DialogPanelContent, 6 | DialogPanelFooter, 7 | TextArea, 8 | TextField, 9 | } from "@codeui/kit"; 10 | import { makeAsyncAction } from "statebuilder/asyncAction"; 11 | import { createSignal } from "solid-js"; 12 | import { ControlledDialogProps } from "../../../core/utils/controlledDialog"; 13 | import { createNewProject, ProjectView } from "../../../core/services/projects"; 14 | 15 | interface NewProjectDialogProps extends ControlledDialogProps { 16 | onSave: (project: ProjectView) => void; 17 | } 18 | 19 | interface Form { 20 | name: string; 21 | description: string; 22 | } 23 | 24 | export function NewProjectDialog(props: NewProjectDialogProps) { 25 | const [submitted, setSubmitted] = createSignal(false); 26 | const [form, setForm] = createStore({ 27 | name: "", 28 | description: "", 29 | }); 30 | 31 | const formValid = () => !!form.name; 32 | 33 | const onSave = (projectView: ProjectView) => { 34 | props.onOpenChange(false); 35 | props.onSave(projectView); 36 | }; 37 | 38 | const saveAction = makeAsyncAction((data: Form) => 39 | createNewProject(data.name, data.description) 40 | .then((result) => onSave(result.data!)) 41 | .catch(() => { 42 | // TODO add error toast 43 | alert("Error"); 44 | }), 45 | ); 46 | 47 | const validations = { 48 | name: { 49 | state: () => (submitted() && !form.name ? "invalid" : undefined), 50 | errorMessage: () => "The field is required", 51 | }, 52 | }; 53 | 54 | return ( 55 | 61 | 62 | 63 | setForm("name", value)} 72 | /> 73 | 74 | setForm("description", value)} 84 | /> 85 | 86 | 87 | 88 | 89 | props.onOpenChange(false)} 93 | > 94 | Cancel 95 | 96 | { 100 | setSubmitted(true); 101 | if (formValid()) { 102 | saveAction(unwrap(form)); 103 | } 104 | }} 105 | > 106 | Save 107 | 108 | 109 | 110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /src/components/Auth/Auth.tsx: -------------------------------------------------------------------------------- 1 | import { HankoAuth } from "./HankoAuth"; 2 | import * as styles from "./Auth.css"; 3 | import { onMount, Show } from "solid-js"; 4 | import { provideState } from "statebuilder"; 5 | import { AuthState } from "../../core/state/auth"; 6 | import { LoadingCircle } from "../../icons/LoadingCircle"; 7 | import { animate, timeline, TimelineDefinition } from "motion"; 8 | import { createBreakpoints } from "../../core/utils/breakpoint"; 9 | 10 | export function Auth() { 11 | const auth = provideState(AuthState); 12 | const breakpoint = createBreakpoints(); 13 | let element!: HTMLDivElement; 14 | let backdropEl!: HTMLDivElement; 15 | let leftContainerImage!: HTMLImageElement; 16 | let hankoLogoRef!: HTMLImageElement; 17 | 18 | // 0.25, 1, 0.5, 1 easeOutQuart 19 | const easeOutExpo = [0.16, 1, 0.3, 1] as const; 20 | onMount(() => { 21 | if (breakpoint.lg) { 22 | const tl = timeline([ 23 | [ 24 | element, 25 | { 26 | opacity: [0, 1], 27 | transform: [ 28 | `translateY(200px) scale(0.75)`, 29 | `translateY(0px) scale(1)`, 30 | ], 31 | }, 32 | { duration: 0.75, easing: easeOutExpo }, 33 | ], 34 | [ 35 | leftContainerImage, 36 | { 37 | opacity: [0.2, 1], 38 | transform: [`translateY(100%) scale(1)`, `translateY(0) scale(2)`], 39 | }, 40 | { duration: 0.5, at: 0.35, easing: [0.22, 1, 0.36, 1] }, 41 | ], 42 | [ 43 | hankoLogoRef, 44 | { 45 | opacity: [0, 1], 46 | transform: [`scale(1.5)`, `translateY(0) scale(1)`], 47 | }, 48 | { 49 | duration: 0.5, 50 | at: 0.6, 51 | easing: easeOutExpo, 52 | }, 53 | ], 54 | ]); 55 | 56 | tl.finished.then(() => { 57 | leftContainerImage.classList.add(styles.leftContainerImage2); 58 | }); 59 | } else { 60 | animate( 61 | hankoLogoRef, 62 | { 63 | opacity: [0, 1], 64 | transform: [`scale(1.5)`, `translateY(0) scale(1)`], 65 | }, 66 | { delay: 0.2 }, 67 | ); 68 | } 69 | }); 70 | 71 | return ( 72 | 75 | 76 | 81 | 82 | 83 | 84 | 85 | 86 | 90 | 91 | 95 | SpecFlow 96 | 97 | with 98 | 99 | 100 | 101 | 102 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditSettingsDialog/ProjectEditSettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogPanelContent, 5 | DialogPanelFooter, 6 | TextArea, 7 | TextField, 8 | } from "@codeui/kit"; 9 | import { makeAsyncAction } from "statebuilder/asyncAction"; 10 | import { createStore, unwrap } from "solid-js/store"; 11 | import { createEffect, createSignal } from "solid-js"; 12 | import { 13 | Project, 14 | updateProjectSettings, 15 | } from "../../../core/services/projects"; 16 | import { ControlledDialogProps } from "../../../core/utils/controlledDialog"; 17 | 18 | interface ProjectEditSettingsDialogProps extends ControlledDialogProps { 19 | onSave: (project: Project) => void; 20 | project: Project; 21 | } 22 | 23 | interface Form { 24 | name: string; 25 | description: string; 26 | } 27 | 28 | export function ProjectEditSettingsDialog( 29 | props: ProjectEditSettingsDialogProps, 30 | ) { 31 | const [submitted, setSubmitted] = createSignal(false); 32 | const [form, setForm] = createStore({ 33 | name: props.project.name, 34 | description: props.project.description ?? "", 35 | }); 36 | 37 | createEffect( 38 | () => props.project, 39 | setForm({ 40 | description: props.project.description ?? "", 41 | name: props.project.name, 42 | }), 43 | ); 44 | 45 | const onSave = (project: Project) => { 46 | props.onSave(project); 47 | }; 48 | 49 | const saveAction = makeAsyncAction((data: Form) => 50 | updateProjectSettings(props.project.id, data) 51 | .then((result) => onSave(result.data!)) 52 | .catch(() => { 53 | // TODO add error toast 54 | alert("Error"); 55 | }) 56 | .finally(() => props.onOpenChange(false)), 57 | ); 58 | 59 | const validations = { 60 | name: { 61 | state: () => (submitted() && !form.name ? "invalid" : undefined), 62 | errorMessage: () => "The field is required", 63 | }, 64 | }; 65 | 66 | return ( 67 | 73 | 74 | 75 | setForm("name", value)} 83 | /> 84 | 85 | setForm("description", value)} 92 | /> 93 | 94 | 95 | 96 | 97 | props.onOpenChange(false)} 101 | > 102 | Cancel 103 | 104 | { 108 | setSubmitted(true); 109 | if ( 110 | Object.values(validations).some( 111 | ({ state }) => state() === "invalid", 112 | ) 113 | ) { 114 | return; 115 | } 116 | saveAction(unwrap(form)); 117 | }} 118 | > 119 | Save 120 | 121 | 122 | 123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/core/services/projects.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from "../supabase"; 2 | import { Tables, Views } from "../../types/supabase"; 3 | import { DIAGRAMS } from "../constants/diagrams"; 4 | 5 | export type ProjectPageView = Views<"project_page_view">; 6 | 7 | export type Project = Tables<"project">; 8 | 9 | export type ProjectView = Views<"project_view"> & { 10 | project_page: ProjectPageView[]; 11 | }; 12 | 13 | export async function getProjects() { 14 | const res = await supabase 15 | .from("project") 16 | .select("*") 17 | .order("created_at", { ascending: false }); 18 | return res.data ?? []; 19 | } 20 | 21 | export async function getProject(id: string): Promise { 22 | const res = await supabase 23 | .from("project_view") 24 | .select(`*, project_page:project_page_view(*)`) 25 | .eq("id", id) 26 | .maybeSingle(); 27 | return res.data; 28 | } 29 | 30 | export async function getProjectPage( 31 | id: string, 32 | ): Promise { 33 | const res = await supabase 34 | .from("project_page_view") 35 | .select("*") 36 | .eq("id", id) 37 | .maybeSingle(); 38 | return res.data; 39 | } 40 | 41 | export async function deleteProject(id: string): Promise { 42 | const res = await supabase.from("project").delete().eq("id", id); 43 | return res.data; 44 | } 45 | 46 | export async function deleteProjectPage( 47 | id: string, 48 | ): Promise { 49 | const res = await supabase.from("project_page").delete().eq("id", id); 50 | return res.data; 51 | } 52 | 53 | export async function createNewProject(name: string, description: string) { 54 | return supabase 55 | .from("project") 56 | .insert({ 57 | name: name, 58 | description, 59 | }) 60 | .select("*, project_page(*)") 61 | .single(); 62 | } 63 | 64 | export async function createProjectPageText( 65 | projectId: string, 66 | data: { 67 | name: string; 68 | content?: string; 69 | }, 70 | ) { 71 | return supabase 72 | .from("project_page") 73 | .insert({ 74 | name: data.name, 75 | description: "", 76 | content: { 77 | type: "page", 78 | content: "", 79 | metadata: {}, 80 | }, 81 | project_id: projectId, 82 | type: "page", 83 | }) 84 | .select() 85 | .single(); 86 | } 87 | 88 | export async function createProjectPage( 89 | projectId: string, 90 | data: { 91 | name: string; 92 | description: string; 93 | diagramType: keyof typeof DIAGRAMS; 94 | content?: string; 95 | }, 96 | ) { 97 | return supabase 98 | .from("project_page") 99 | .insert({ 100 | name: data.name, 101 | description: data.description, 102 | content: { 103 | type: "diagram", 104 | content: data.content || DIAGRAMS[data.diagramType].example, 105 | metadata: { 106 | diagramType: "sequenceDiagram", 107 | }, 108 | }, 109 | project_id: projectId, 110 | type: "diagram", 111 | }) 112 | .select() 113 | .single(); 114 | } 115 | 116 | export async function updateProjectSettings( 117 | id: string, 118 | data: { name: string; description: string }, 119 | ) { 120 | return supabase 121 | .from("project") 122 | .update({ name: data.name, description: data.description }) 123 | .eq("id", id) 124 | .select() 125 | .single(); 126 | } 127 | 128 | export async function updateProjectPageSettings( 129 | id: string, 130 | data: { name: string; description: string }, 131 | ) { 132 | return supabase 133 | .from("project_page") 134 | .update({ name: data.name, description: data.description }) 135 | .eq("id", id) 136 | .select() 137 | .single(); 138 | } 139 | 140 | export async function updateProjectContent( 141 | id: string, 142 | content: Record, 143 | ) { 144 | return supabase 145 | .from("project_page") 146 | .update({ content }) 147 | .eq("id", id) 148 | .select(); 149 | } 150 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorPageSettingsDialog/ProjectEditorPageSettingsDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogPanelContent, 5 | DialogPanelFooter, 6 | TextArea, 7 | TextField, 8 | } from "@codeui/kit"; 9 | import { 10 | ProjectPageView, 11 | updateProjectPageSettings, 12 | } from "../../../../core/services/projects"; 13 | import { makeAsyncAction } from "statebuilder/asyncAction"; 14 | import { createStore, unwrap } from "solid-js/store"; 15 | import { createEffect, createSignal } from "solid-js"; 16 | import { ControlledDialogProps } from "../../../../core/utils/controlledDialog"; 17 | 18 | interface ProjectEditorPageSettingsDialogProps extends ControlledDialogProps { 19 | onSave: (projectPageView: ProjectPageView) => void; 20 | projectPage: ProjectPageView; 21 | } 22 | 23 | interface Form { 24 | name: string; 25 | description: string; 26 | } 27 | 28 | export function ProjectEditorPageSettingsDialog( 29 | props: ProjectEditorPageSettingsDialogProps, 30 | ) { 31 | const [submitted, setSubmitted] = createSignal(false); 32 | const [form, setForm] = createStore({ 33 | name: props.projectPage.name, 34 | description: props.projectPage.description ?? "", 35 | }); 36 | 37 | createEffect( 38 | () => props.projectPage, 39 | setForm({ 40 | description: props.projectPage.description ?? "", 41 | name: props.projectPage.name, 42 | }), 43 | ); 44 | 45 | const onSave = (projectPageView: ProjectPageView) => { 46 | props.onSave(projectPageView); 47 | }; 48 | 49 | const saveAction = makeAsyncAction((data: Form) => 50 | updateProjectPageSettings(props.projectPage.id, data) 51 | .then((result) => onSave({ ...props.projectPage, ...result.data! })) 52 | .catch(() => { 53 | // TODO add error toast 54 | alert("Error"); 55 | }) 56 | .finally(() => props.onOpenChange(false)), 57 | ); 58 | 59 | const validations = { 60 | name: { 61 | state: () => (submitted() && !form.name ? "invalid" : undefined), 62 | errorMessage: () => "The field is required", 63 | }, 64 | }; 65 | 66 | return ( 67 | 73 | 74 | 75 | setForm("name", value)} 83 | /> 84 | 85 | setForm("description", value)} 92 | /> 93 | 94 | 95 | 96 | 97 | props.onOpenChange(false)} 101 | > 102 | Cancel 103 | 104 | { 108 | setSubmitted(true); 109 | if ( 110 | Object.values(validations).some( 111 | ({ state }) => state() === "invalid", 112 | ) 113 | ) { 114 | return; 115 | } 116 | saveAction(unwrap(form)); 117 | }} 118 | > 119 | Save 120 | 121 | 122 | 123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorContent/ProjectEditorContent.tsx: -------------------------------------------------------------------------------- 1 | import { provideState } from "statebuilder"; 2 | import { EditorState } from "../editorState"; 3 | import { lazy, Match, Show, Switch } from "solid-js"; 4 | import { ProjectPageView } from "../../../../core/services/projects"; 5 | import { DiagramEditor } from "../../../DiagramEditor/DiagramEditor"; 6 | import { LoadingCircle } from "../../../../icons/LoadingCircle"; 7 | import { PreviewState } from "../previewState"; 8 | import { PageEditor } from "../../../PageEditor/PageEditor"; 9 | 10 | interface DiagramEditorContentProps { 11 | previewMode: string; 12 | page: ProjectPageView; 13 | disabled?: boolean; 14 | onValueChange: (value: string) => void; 15 | onSaveShortcut: () => void; 16 | } 17 | 18 | function DiagramEditorContent(props: DiagramEditorContentProps) { 19 | const previewState = provideState(PreviewState); 20 | 21 | const DiagramEditor = lazy(() => 22 | import("../../../DiagramEditor/DiagramEditor").then( 23 | ({ DiagramEditor }) => ({ 24 | default: DiagramEditor, 25 | }), 26 | ), 27 | ); 28 | 29 | return ( 30 | previewState.actions.setRef(ref)} 39 | /> 40 | ); 41 | } 42 | 43 | function PageEditorContent(props: DiagramEditorContentProps) { 44 | const PageEditor = lazy(() => 45 | import("../../../PageEditor/PageEditor").then(({ PageEditor }) => ({ 46 | default: PageEditor, 47 | })), 48 | ); 49 | 50 | return ( 51 | 59 | ); 60 | } 61 | 62 | export function ProjectEditorContent() { 63 | const editorState = provideState(EditorState); 64 | 65 | return ( 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | {(selectedPage) => ( 75 | 76 | 77 | 78 | void 0} 82 | onValueChange={(content) => { 83 | editorState.actions.updateProjectViewContent({ 84 | id: selectedPage.id, 85 | content, 86 | }); 87 | }} 88 | page={selectedPage} 89 | /> 90 | 91 | 92 | void 0} 96 | onValueChange={(content) => { 97 | editorState.actions.updateProjectViewContent({ 98 | id: selectedPage.id, 99 | content, 100 | }); 101 | }} 102 | page={selectedPage} 103 | /> 104 | 105 | 106 | 107 | )} 108 | 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/components/Editor/MermaidEditor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createCodeMirror, 3 | createEditorControlledValue, 4 | createEditorFocus, 5 | createEditorReadonly, 6 | createLazyCompartmentExtension, 7 | } from "solid-codemirror"; 8 | import { 9 | EditorView, 10 | highlightActiveLine, 11 | highlightActiveLineGutter, 12 | KeyBinding, 13 | keymap, 14 | lineNumbers, 15 | } from "@codemirror/view"; 16 | import { VoidProps } from "solid-js"; 17 | import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; 18 | import { 19 | bracketMatching, 20 | HighlightStyle, 21 | indentOnInput, 22 | syntaxHighlighting, 23 | } from "@codemirror/language"; 24 | import { autocompletion, closeBracketsKeymap } from "@codemirror/autocomplete"; 25 | import { sequenceTags } from "codemirror-lang-mermaid"; 26 | import { theme } from "./theme"; 27 | 28 | interface JsonEditorProps { 29 | value: string; 30 | onValueChange: (value: string) => void; 31 | onSave: () => void; 32 | disabled?: boolean; 33 | type: string; 34 | } 35 | 36 | export function MermaidEditor(props: VoidProps) { 37 | const { ref, createExtension, editorView } = createCodeMirror({ 38 | onValueChange: props.onValueChange, 39 | }); 40 | 41 | createEditorControlledValue(editorView, () => props.value); 42 | 43 | createEditorReadonly(editorView, () => props.disabled ?? false); 44 | 45 | createEditorFocus(editorView, (focused) => { 46 | if (!focused) { 47 | props.onSave(); 48 | } 49 | }); 50 | 51 | const saveKeymap: KeyBinding = { 52 | key: "Ctrl-s", 53 | preventDefault: true, 54 | run: (editor) => { 55 | props.onSave(); 56 | return editor.hasFocus; 57 | }, 58 | }; 59 | 60 | createExtension([ 61 | lineNumbers(), 62 | highlightActiveLine(), 63 | highlightActiveLineGutter(), 64 | indentOnInput(), 65 | history(), 66 | bracketMatching(), 67 | autocompletion({ 68 | defaultKeymap: true, 69 | icons: true, 70 | aboveCursor: true, 71 | activateOnTyping: true, 72 | }), 73 | keymap.of([ 74 | ...closeBracketsKeymap, 75 | ...defaultKeymap, 76 | ...historyKeymap, 77 | saveKeymap, 78 | ]), 79 | ]); 80 | 81 | const sequenceTagsTheme = HighlightStyle.define([ 82 | { tag: sequenceTags.diagramName, color: "#8ABEB7" }, 83 | { tag: sequenceTags.arrow, color: "#A9A9A9" }, 84 | { tag: sequenceTags.keyword1, color: "#B58E66" }, 85 | { tag: sequenceTags.keyword2, color: "#B58E66" }, 86 | { tag: sequenceTags.lineComment, color: "#696969" }, 87 | { tag: sequenceTags.messageText1, color: "#98C379" }, 88 | { tag: sequenceTags.messageText2, color: "#98C379" }, 89 | { tag: sequenceTags.nodeText, color: "#E06C75" }, 90 | { tag: sequenceTags.position, color: "#D19A66" }, 91 | ]); 92 | 93 | createLazyCompartmentExtension(() => { 94 | return Promise.all([ 95 | import("codemirror-lang-mermaid").then(({ mermaid }) => mermaid()), 96 | syntaxHighlighting(sequenceTagsTheme), 97 | ]); 98 | }, editorView); 99 | 100 | // createExtension(() => readOnlyTransactionFilter()); 101 | createExtension(theme); 102 | 103 | createExtension(() => [ 104 | // EditorView.updateListener.of((vu) => { 105 | // if (!vu.docChanged) return; 106 | // disabledLineSelection(vu.view, 0, props.type.length + 1); 107 | // return false; 108 | // }), 109 | EditorView.theme({ 110 | "&": { 111 | height: "100%", 112 | "min-width": "fit-content", 113 | "max-width": "100%", 114 | }, 115 | ".cm-line:first-child": { 116 | "user-select": "none", 117 | opacity: 1, 118 | }, 119 | ".cm-disabled-line": { 120 | "user-select": "none", 121 | }, 122 | ".cm-scroller": { 123 | overflow: "hidden", 124 | }, 125 | }), 126 | ]); 127 | 128 | return ( 129 | <> 130 | 138 | > 139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorExportDialog/ProjectEditorExportDiagramDialog.tsx: -------------------------------------------------------------------------------- 1 | import { ControlledDialogProps } from "../../../../core/utils/controlledDialog"; 2 | import { 3 | Button, 4 | Checkbox, 5 | Dialog, 6 | DialogPanelContent, 7 | DialogPanelFooter, 8 | } from "@codeui/kit"; 9 | import { 10 | SegmentedControl, 11 | SegmentedControlItem, 12 | } from "../../../../ui/SegmentedControl/SegmentedControl"; 13 | import { createSignal, Match, Switch } from "solid-js"; 14 | import { provideState } from "statebuilder"; 15 | import { PreviewState } from "../previewState"; 16 | import { ProjectPageView } from "../../../../core/services/projects"; 17 | import { DynamicSizedContainer } from "../../../../ui/DynamicSizedContainer/DynamicSizedContainer"; 18 | 19 | interface ProjectEditorExportDiagramDialog extends ControlledDialogProps { 20 | projectPage: ProjectPageView; 21 | } 22 | 23 | export function ProjectEditorExportDiagramDialog( 24 | props: ProjectEditorExportDiagramDialog, 25 | ) { 26 | const [extension, setExtension] = createSignal("png"); 27 | const [showBackground, setShowBackground] = createSignal(true); 28 | const [scale, setScale] = createSignal(6); 29 | 30 | const { exportAndSave } = provideState(PreviewState); 31 | 32 | return ( 33 | 39 | 40 | 41 | 42 | {/* TODO: bad */} 43 | 44 | File extension 45 | 46 | 47 | PNG 48 | SVG 49 | 50 | 51 | 52 | 53 | 54 | Scale 55 | 56 | setScale(parseInt(value))} 59 | > 60 | 1x 61 | 3x 62 | 6x 63 | 12x 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | props.onOpenChange(false)} 90 | > 91 | Close 92 | 93 | 99 | exportAndSave({ 100 | type: extension() as "png" | "svg", 101 | scale: scale(), 102 | fileName: props.projectPage.name, 103 | showBackground: showBackground(), 104 | }) 105 | } 106 | > 107 | Export 108 | 109 | 110 | 111 | 112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /docs/local_development.md: -------------------------------------------------------------------------------- 1 | # Local Development 2 | 3 | ## Table of contents 4 | 5 | - [1. Preparing the environment](#1-preparing-the-environment) 6 | - [2. Connect Hanko](#2-init-hanko) 7 | - [3. Initialize supabase](#3-initialize-supabase) 8 | - [3.1 Connect an external instance](#31-connect-an-external-instance) 9 | - [3.2 Connect to a local instance](#32-connect-to-a-local-instance) 10 | - [3.3 Setup environment variables](#33-setup-environment-variables) 11 | - [4. Enable mocks for client-side authentication flow (optional)](#4-enable-mocks-for-client-side-authentication-flow-optional) 12 | - [5. Run the dev servers](#5-run-the-dev-servers) 13 | 14 | ## 1. Preparing the environment 15 | 16 | This repository uses [pnpm](https://pnpm.io/it/). You need to install **pnpm 8** 17 | and **Node.js v16** or higher. 18 | 19 | You can run the following commands in your terminal to check your local Node.js and npm versions: 20 | 21 | ```bash 22 | node -v 23 | pnpm -v 24 | ``` 25 | 26 | From the project root directory, you can run the following command to install the project's dependencies: 27 | 28 | ```bash 29 | pnpm install 30 | ``` 31 | 32 | Next, you have to rename and modify the `.env.example` in order to put the environment variables needed by the app. 33 | 34 | ```bash 35 | cp .env.example .env.local # or .env 36 | ``` 37 | 38 | Env variables will be loaded by vite: https://vitejs.dev/guide/env-and-mode.html 39 | 40 | ## 2. Init Hanko 41 | 42 | In order to init hanko authentication, you must sign-up to their website and register a new project. 43 | 44 | https://www.hanko.io/ 45 | 46 | > [!IMPORTANT] 47 | > Consider that Hanko does not currently support multiple app/redirect urls, so to integrate their 48 | > frontend components you should add "http://localhost:3000" as the App URL in their Dashboard -> Settings -> General 49 | > page. 50 | 51 | You can follow their setup guide to init a new Hanko Cloud project: https://docs.hanko.io/setup-hanko-cloud 52 | 53 | Once that, you should populate the variable in the `.env` file in order to integrate the authentication in the client 54 | app. 55 | 56 | ```dotenv 57 | VITE_HANKO_API_URL=https://f4****-4802-49ad-8e0b-3d3****ab32.hanko.io # just an example 58 | ``` 59 | 60 | ## 3. Initialize Supabase 61 | 62 | SpecFlow integrates [supabase](https://supabase.com/) for database service and edge functions. You can use an existing 63 | hosted instance or provide a self-hosted one. 64 | 65 | ### 3.1 Connect an external instance 66 | 67 | You can follow the supabase documentation and wizard to bootstrap a new remote instance for free. 68 | 69 | https://supabase.com/docs/guides/getting-started 70 | 71 | ### 3.2 Connect to a local instance 72 | 73 | To run supabase locally you can run the command below, or follow their local development guide for more details. 74 | 75 | ```bash 76 | pnpm supabase start 77 | ``` 78 | 79 | You can follow the supabase official local development guide for more details. 80 | 81 | https://supabase.com/docs/guides/cli/local-development 82 | 83 | ### 3.3 Setup environment variables 84 | 85 | Once supabase is initialized, you should setup the environment variables needed to access the supabase instance 86 | from the client-side library. 87 | 88 | ```dotenv 89 | # If you are running supabase locally, put http://localhost:3000, 90 | # otherwise you should retrieve it from the dashboard, 91 | VITE_CLIENT_SUPABASE_URL= 92 | # If you are running supabase locally, put the `anon key` retrieved by the `pnpm supabase status` command, 93 | # otherwise you should retrieve it from the dashboard, 94 | VITE_CLIENT_SUPABASE_KEY= 95 | ``` 96 | 97 | ### 4. Enable mocks for client-side authentication flow (optional) 98 | 99 | If you want to skip Hanko's authentication process locally, you can enable the dedicated environment variable. More 100 | information about it in the [Hanko integration section](#hanko-integration-details). 101 | 102 | ```dotenv 103 | VITE_ENABLE_AUTH_MOCK=true 104 | ``` 105 | 106 | ## 5. Run the dev servers 107 | 108 | Once everything is started, you can run the command `supabase:serve:functions` to run the lambda locally 109 | 110 | ```bash 111 | pnpm supabase:serve:functions 112 | ``` 113 | 114 | > [!IMPORTANT] 115 | > Supabase functions need some environment variables to run correctly (without mocks). 116 | 117 | ```dotenv 118 | # supabase/.env.local 119 | HANKO_API_URL=https://f4****-4802-49ad-8e0b-3d3****ab32.hanko.io 120 | PRIVATE_KEY_SUPABASE= 121 | OPENAI_TOKEN= 122 | SKIP_AUTH= 123 | ``` 124 | 125 | To run the application locally, you can run the `dev` command. 126 | 127 | ```bash 128 | pnpm dev 129 | ``` 130 | -------------------------------------------------------------------------------- /src/core/state/auth.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedError, User } from "@teamhanko/hanko-elements"; 2 | import { defineStore } from "statebuilder"; 3 | import { withProxyCommands } from "statebuilder/commands"; 4 | import { useNavigate } from "@solidjs/router"; 5 | import { withHanko, withHankoContext } from "./hanko"; 6 | import { createEffect, createMemo, on } from "solid-js"; 7 | import { 8 | getSupabaseCookie, 9 | patchSupabaseRestClient, 10 | syncSupabaseTokenFromHankoSession, 11 | } from "../supabase"; 12 | import { signSupabaseToken } from "../services/auth"; 13 | import { createControlledDialog } from "../utils/controlledDialog"; 14 | import { ProfileDialog } from "../../components/Auth/ProfileDialog"; 15 | 16 | type AuthCommands = { 17 | setCurrent: User | null; 18 | setLoading: boolean; 19 | setReady: boolean; 20 | setSupabaseAccessToken: string | null; 21 | forceLogout: void; 22 | }; 23 | 24 | interface State { 25 | user: User | null; 26 | loading: boolean; 27 | ready: boolean; 28 | supabaseAccessToken: string | null; 29 | } 30 | 31 | const enableAuthMock = import.meta.env.VITE_ENABLE_AUTH_MOCK; 32 | 33 | function initialState() { 34 | return { 35 | loading: false, 36 | ready: false, 37 | supabaseAccessToken: null, 38 | user: null, 39 | }; 40 | } 41 | 42 | export const AuthState = defineStore(initialState) 43 | .extend(withHankoContext(enableAuthMock)) 44 | .extend(withProxyCommands({ devtools: { storeName: "auth" } })) 45 | .extend(withHanko()) 46 | .extend((_) => { 47 | _.hold(_.commands.setCurrent, (user) => _.set("user", () => user)); 48 | _.hold(_.commands.setLoading, (loading) => _.set("loading", () => loading)); 49 | _.hold(_.commands.setReady, (ready) => _.set("ready", () => ready)); 50 | _.hold(_.commands.setSupabaseAccessToken, (token) => 51 | _.set("supabaseAccessToken", () => token), 52 | ); 53 | }) 54 | .extend((_) => ({ 55 | loadCurrentUser() { 56 | return _.getCurrentUser().catch((e) => { 57 | if (e instanceof UnauthorizedError) { 58 | return null; 59 | } 60 | }); 61 | }, 62 | })) 63 | .extend((_, context) => { 64 | const navigate = useNavigate(); 65 | const loggedIn = () => !!_.get.supabaseAccessToken && !!_(); 66 | 67 | context.hooks.onInit(() => { 68 | _.loadCurrentUser().then((user) => { 69 | if (!user) { 70 | _.actions.setSupabaseAccessToken(null); 71 | navigate("/login"); 72 | } else { 73 | _.actions.setSupabaseAccessToken(getSupabaseCookie()); 74 | _.actions.setCurrent(user ?? null); 75 | } 76 | _.actions.setReady(true); 77 | }); 78 | 79 | _.hanko.onAuthFlowCompleted(() => { 80 | _.actions.setLoading(true); 81 | _.loadCurrentUser().then((user) => { 82 | _.actions.setCurrent(user ?? null); 83 | signSupabaseToken(_.hanko.session.get()) 84 | .then(({ access_token }) => 85 | _.actions.setSupabaseAccessToken(access_token), 86 | ) 87 | .then(() => _.actions.setLoading(false)) 88 | .then(() => navigate("/")) 89 | .catch(() => { 90 | _.actions.setLoading(false); 91 | return _.hanko.user.logout().then(() => navigate("/login")); 92 | }); 93 | }); 94 | }); 95 | 96 | createEffect( 97 | on( 98 | createMemo(() => _.get.supabaseAccessToken), 99 | (accessToken) => { 100 | patchSupabaseRestClient(accessToken); 101 | syncSupabaseTokenFromHankoSession(accessToken, _.session()); 102 | }, 103 | { defer: true }, 104 | ), 105 | ); 106 | 107 | _.hanko.onSessionCreated((session) => { 108 | _.actions.setSupabaseAccessToken(session.jwt!); 109 | }); 110 | _.hanko.onSessionExpired(() => { 111 | _.actions.setSupabaseAccessToken(null); 112 | }); 113 | _.hanko.onUserLoggedOut(() => { 114 | _.actions.setSupabaseAccessToken(null); 115 | }); 116 | }); 117 | 118 | const controlledDialog = createControlledDialog(); 119 | 120 | return { 121 | goToProfile() { 122 | controlledDialog(ProfileDialog, {}); 123 | }, 124 | loggedIn, 125 | logout() { 126 | return _.hanko.user 127 | .logout() 128 | .then(() => navigate("/login")) 129 | .catch((e) => { 130 | console.error("Error during logout:", e); 131 | // TODO add toast 132 | alert("Error while logout"); 133 | }); 134 | }, 135 | }; 136 | }); 137 | -------------------------------------------------------------------------------- /src/components/Projects/Projects.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useNavigate } from "@solidjs/router"; 2 | import { createResource, ErrorBoundary, For, Show, Suspense } from "solid-js"; 3 | import { getProjects, Project } from "../../core/services/projects"; 4 | import { Button, IconButton, Tooltip } from "@codeui/kit"; 5 | import { CurrentUserBadge } from "../../ui/UserBadge/CurrentUserBadge"; 6 | import { createControlledDialog } from "../../core/utils/controlledDialog"; 7 | import { NewProjectDialog } from "./NewProjectDialog/NewProjectDialog"; 8 | import { LoadingCircleWithBackdrop } from "../../icons/LoadingCircle"; 9 | import { ReloadIcon } from "../../icons/ReloadIcon"; 10 | import { ProjectCard } from "./ProjectCard/ProjectCard"; 11 | import { PlatformState } from "../../core/state/platform"; 12 | import { provideState } from "statebuilder"; 13 | 14 | export function Projects() { 15 | const links = [{ path: "/projects", label: "Dashboard" }]; 16 | const navigate = useNavigate(); 17 | const [projects, { refetch, mutate }] = createResource(getProjects); 18 | const platformState = provideState(PlatformState); 19 | 20 | const controlledDialog = createControlledDialog(); 21 | 22 | const canCreateNewProject = () => { 23 | if (projects.state !== "ready") { 24 | return false; 25 | } 26 | return (projects()?.length ?? 0) < platformState().max_project_row_per_user; 27 | }; 28 | 29 | const onDeleteProject = ({ id }: Project) => { 30 | mutate((projects) => 31 | (projects ?? []).filter((project) => project.id !== id), 32 | ); 33 | }; 34 | 35 | const onEditProject = ({ id, name, description }: Project) => { 36 | mutate((projects) => 37 | (projects ?? []).map((project) => { 38 | if (project.id === id) { 39 | return { ...project, name, description }; 40 | } 41 | return project; 42 | }), 43 | ); 44 | }; 45 | 46 | const onCreateProject = () => { 47 | controlledDialog(NewProjectDialog, { 48 | onSave: (result) => { 49 | navigate(`/projects/${result.id}/editor`); 50 | }, 51 | }); 52 | }; 53 | 54 | return ( 55 | 56 | 61 | 62 | 63 | 64 | {(link, index) => ( 65 | <> 66 | 67 | {link.label} 68 | 69 | / 70 | > 71 | )} 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 85 | 86 | My projects 87 | 88 | 93 | 94 | 95 | 100 | 105 | Create project 106 | 107 | 108 | 109 | 110 | 111 | ( 113 | 118 | An error occurred. 119 | {err.message} 120 | 121 | Reload 122 | 123 | 124 | )} 125 | > 126 | 127 | 128 | } 130 | > 131 | 132 | {(project) => ( 133 | 138 | )} 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorSidebar/ProjectEditorSidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as styles from "./ProjectEditorSidebar.css"; 2 | import { For, getOwner, Match, Switch } from "solid-js"; 3 | import { ProjectView } from "../../../../core/services/projects"; 4 | import { provideState } from "statebuilder"; 5 | import { EditorState } from "../editorState"; 6 | import { PresentationChart } from "../../../../icons/PresentationChart"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuPortal, 12 | DropdownMenuTrigger, 13 | IconButton, 14 | Tooltip, 15 | } from "@codeui/kit"; 16 | import { bgBrand } from "../../../../global.css"; 17 | import { PlusIcon } from "../../../../icons/PlusIcon"; 18 | import { As } from "@kobalte/core"; 19 | import { DocumentTextIcon } from "../../../../icons/DocumentTextIcon"; 20 | import { ProjectEditorShowOnEditable } from "../ProjectEditorShowOnEditable/ProjectEditorShowOnEditable"; 21 | 22 | interface ProjectEditorSidebarProps { 23 | project: ProjectView; 24 | } 25 | 26 | export function ProjectEditorSidebar(props: ProjectEditorSidebarProps) { 27 | const editorState = provideState(EditorState); 28 | const owner = getOwner(); 29 | return ( 30 | 34 | 35 | 36 | Pages 37 | 38 | 39 | 43 | 44 | 45 | 52 | 53 | 54 | 55 | 56 | 57 | } 59 | onClick={() => 60 | editorState.openNewPageDialog(owner!, props.project.id!) 61 | } 62 | > 63 | New page 64 | 65 | } 67 | onClick={() => 68 | editorState.openNewDiagramDialog( 69 | owner!, 70 | props.project.id!, 71 | ) 72 | } 73 | > 74 | New diagram 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | {(page, index) => { 85 | const isActive = () => editorState.get.activePageId === page.id; 86 | // TODO use vanilla extract 87 | return ( 88 | editorState.actions.setActivePage(page.id)} 96 | > 97 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 117 | {page.name} 118 | 119 | 120 | 121 | 122 | 123 | ); 124 | }} 125 | 126 | 127 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the 2 | # working directory name when running `supabase init`. 3 | project_id = "codearchive" 4 | 5 | functions.hanko-auth.verify_jwt = false 6 | functions.generate-diagram.verify_jwt = true 7 | 8 | [api] 9 | enabled = true 10 | # Port to use for the API URL. 11 | port = 54321 12 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 13 | # endpoints. public and storage are always included. 14 | schemas = ["public", "storage", "graphql_public"] 15 | # Extra schemas to add to the search_path of every request. public is always included. 16 | extra_search_path = ["public", "extensions"] 17 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 18 | # for accidental or malicious requests. 19 | max_rows = 1000 20 | 21 | [db] 22 | # Port to use for the local database URL. 23 | port = 54322 24 | # Port used by db diff command to initialise the shadow database. 25 | shadow_port = 54320 26 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 27 | # server_version;` on the remote database to check. 28 | major_version = 15 29 | 30 | [db.pooler] 31 | enabled = false 32 | # Port to use for the local connection pooler. 33 | port = 54329 34 | # Specifies when a server connection can be reused by other clients. 35 | # Configure one of the supported pooler modes: `transaction`, `session`. 36 | pool_mode = "transaction" 37 | # How many server connections to allow per user/database pair. 38 | default_pool_size = 20 39 | # Maximum number of client connections allowed. 40 | max_client_conn = 100 41 | 42 | [realtime] 43 | enabled = true 44 | # Bind realtime via either IPv4 or IPv6. (default: IPv6) 45 | # ip_version = "IPv6" 46 | 47 | [studio] 48 | enabled = true 49 | # Port to use for Supabase Studio. 50 | port = 54323 51 | # External URL of the API server that frontend connects to. 52 | api_url = "http://localhost" 53 | 54 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 55 | # are monitored, and you can view the emails that would have been sent from the web interface. 56 | [inbucket] 57 | enabled = true 58 | # Port to use for the email testing server web interface. 59 | port = 54324 60 | # Uncomment to expose additional ports for testing user applications that send emails. 61 | # smtp_port = 54325 62 | # pop3_port = 54326 63 | 64 | [storage] 65 | enabled = true 66 | # The maximum file size allowed (e.g. "5MB", "500KB"). 67 | file_size_limit = "50MiB" 68 | 69 | [auth] 70 | enabled = false 71 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 72 | # in emails. 73 | site_url = "http://localhost:3000" 74 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 75 | additional_redirect_urls = ["https://localhost:3000"] 76 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). 77 | jwt_expiry = 3600 78 | # If disabled, the refresh token will never expire. 79 | enable_refresh_token_rotation = true 80 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. 81 | # Requires enable_refresh_token_rotation = true. 82 | refresh_token_reuse_interval = 10 83 | # Allow/disallow new user signups to your project. 84 | enable_signup = false 85 | 86 | [auth.email] 87 | # Allow/disallow new user signups via email to your project. 88 | enable_signup = true 89 | # If enabled, a user will be required to confirm any email change on both the old, and new email 90 | # addresses. If disabled, only the new email is required to confirm. 91 | double_confirm_changes = true 92 | # If enabled, users need to confirm their email address before signing in. 93 | enable_confirmations = false 94 | 95 | # Uncomment to customize email template 96 | # [auth.email.template.invite] 97 | # subject = "You have been invited" 98 | # content_path = "./supabase/templates/invite.html" 99 | 100 | [auth.sms] 101 | # Allow/disallow new user signups via SMS to your project. 102 | enable_signup = true 103 | # If enabled, users need to confirm their phone number before signing in. 104 | enable_confirmations = false 105 | 106 | # Use pre-defined map of phone number to OTP for testing. 107 | [auth.sms.test_otp] 108 | # 4152127777 = "123456" 109 | 110 | # Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. 111 | [auth.sms.twilio] 112 | enabled = false 113 | account_sid = "" 114 | message_service_sid = "" 115 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: 116 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" 117 | 118 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 119 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, 120 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 121 | [auth.external.apple] 122 | enabled = false 123 | client_id = "" 124 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: 125 | secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" 126 | # Overrides the default auth redirectUrl. 127 | redirect_uri = "" 128 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 129 | # or any other third-party OIDC providers. 130 | url = "" 131 | 132 | [analytics] 133 | enabled = false 134 | port = 54327 135 | vector_port = 54328 136 | # Configure one of the supported backends: `postgres`, `bigquery`. 137 | backend = "postgres" 138 | -------------------------------------------------------------------------------- /src/components/Editor/theme.ts: -------------------------------------------------------------------------------- 1 | import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; 2 | import { tags as t } from "@lezer/highlight"; 3 | import tokens from "./dark.json"; 4 | import { EditorView } from "@codemirror/view"; 5 | import { Extension } from "@codemirror/state"; 6 | 7 | export const parseColor = (hex: string) => { 8 | let color = tokens.palette[hex as keyof typeof tokens.palette] ?? hex; 9 | return `#${color}`; 10 | }; 11 | 12 | export const highlightStyle: HighlightStyle = HighlightStyle.define([ 13 | { 14 | tag: [t.comment], 15 | color: parseColor(tokens.palette["Gray 90"]), 16 | }, 17 | { 18 | tag: [t.link], 19 | textDecoration: "underline", 20 | }, 21 | { 22 | tag: [t.keyword], 23 | color: parseColor(tokens["text-attributes"].keyword.fgColor), 24 | }, 25 | { 26 | tag: [t.typeOperator], 27 | color: parseColor(tokens["text-attributes"].keyword.fgColor), 28 | }, 29 | { 30 | tag: [t.meta], 31 | color: parseColor(tokens["text-attributes"].metadata.fgColor), 32 | }, 33 | { 34 | tag: [t.number], 35 | color: parseColor(tokens["text-attributes"].number.fgColor), 36 | }, 37 | { 38 | tag: [t.bool], 39 | color: parseColor(tokens["text-attributes"].boolean.fgColor), 40 | }, 41 | { 42 | tag: [t.string], 43 | color: parseColor(tokens["text-attributes"].string.fgColor), 44 | }, 45 | { 46 | tag: [t.special(t.string)], 47 | color: parseColor(tokens["text-attributes"]["string.escape"].fgColor), 48 | }, 49 | { 50 | tag: [t.regexp], 51 | color: parseColor(tokens["text-attributes"]["string.regexp"].fgColor), 52 | }, 53 | { 54 | tag: [t.punctuation], 55 | color: parseColor(tokens["text-attributes"].punctuation.fgColor), 56 | }, 57 | { 58 | tag: [t.variableName], 59 | color: parseColor(tokens["text-attributes"]["identifier.variable"].fgColor), 60 | }, 61 | { 62 | tag: [t.propertyName], 63 | color: parseColor(tokens["text-attributes"].keyword.fgColor), 64 | }, 65 | { 66 | tag: [t.heading], 67 | color: parseColor(tokens["text-attributes"]["markup.heading"].fgColor), 68 | fontWeight: "medium", 69 | }, 70 | { 71 | tag: [t.link], 72 | color: parseColor(tokens["text-attributes"]["markup.href"].fgColor), 73 | fontStyle: "italic", 74 | }, 75 | { 76 | tag: [t.quote], 77 | color: parseColor(tokens["text-attributes"]["markup.code.block"].fgColor), 78 | }, 79 | { 80 | tag: [t.list], 81 | color: parseColor(tokens["text-attributes"]["markup.code.block"].fgColor), 82 | }, 83 | ]); 84 | 85 | export const colors = EditorView.theme( 86 | { 87 | "&": { 88 | color: parseColor(tokens.palette["Gray 120"]), 89 | background: parseColor(tokens.palette["Gray 10"]), 90 | fontSize: "15px", 91 | }, 92 | "&.cm-editor.cm-focused": { 93 | outline: "none", 94 | border: "none", 95 | }, 96 | ".cm-content": { 97 | fontFamily: "JetBrains Mono, Inter, monospace", 98 | padding: "0px", 99 | }, 100 | ".cm-gutters": { 101 | backgroundColor: "transparent", 102 | border: "none", 103 | }, 104 | ".cm-line": { 105 | padding: "2px 2px 2px 12px", 106 | }, 107 | ".cm-gutters .cm-gutter.cm-lineNumbers .cm-gutterElement": { 108 | color: parseColor(tokens.colors["editor.lineNumber.text"]), 109 | paddingLeft: "12px", 110 | fontWeight: 600, 111 | display: "flex", 112 | justifyContent: "flex-end", 113 | alignItems: "center", 114 | }, 115 | "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": 116 | { 117 | backgroundColor: parseColor( 118 | tokens["text-attributes"]["editor.selection"].bgColor, 119 | ), 120 | }, 121 | "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { 122 | backgroundColor: parseColor(tokens.colors["editor.folded.fill"]), 123 | }, 124 | ".cm-line.cm-activeLine": { 125 | backgroundColor: parseColor(tokens.colors["editor.currentLine.fill"]), 126 | }, 127 | ".cm-gutterElement.cm-activeLineGutter": { 128 | backgroundColor: parseColor(tokens.colors["editor.currentLine.fill"]), 129 | }, 130 | ".cm-tooltip": { 131 | backgroundColor: parseColor(tokens.colors["tooltip.fill"]), 132 | border: `1px solid ${parseColor(tokens.colors["tooltip.border"])}`, 133 | color: parseColor(tokens.colors["tooltip.text"]), 134 | borderRadius: "6px", 135 | overflow: "hidden", 136 | boxShadow: 137 | "rgba(0, 0, 0, 0.4) 0px 19px 38px, rgba(0, 0, 0, 0.22) 0px 15px 12px", 138 | }, 139 | ".cm-tooltip .cm-tooltip-lint.cm-tooltip-section .cm-diagnostic.cm-diagnostic-error": 140 | { 141 | border: "none", 142 | }, 143 | ".cm-tooltip-autocomplete": { 144 | "& > ul > li": { 145 | fontFamily: "JetBrains Mono, Inter, monospace", 146 | fontSize: "14px", 147 | padding: "4px !important", 148 | paddingRight: "8px !important", 149 | paddingLeft: "8px !important", 150 | }, 151 | "& > ul > li[aria-selected]": { 152 | backgroundColor: parseColor(tokens.colors["input.selection.fill"]), 153 | }, 154 | "& > ul > li > div.cm-completionIcon": { 155 | marginRight: "4px !important", 156 | fontSize: "14px", 157 | }, 158 | }, 159 | "::-webkit-scrollbar": { 160 | width: "12px", 161 | backgroundColor: "transparent", 162 | }, 163 | "::-webkit-scrollbar-track": { 164 | backgroundColor: "transparent", 165 | }, 166 | "::-webkit-scrollbar-thumb": { 167 | backgroundColor: "#505050", 168 | borderRadius: "1000px", 169 | border: "4px solid transparent", 170 | backgroundClip: "content-box", 171 | transition: "background-color .2s", 172 | }, 173 | }, 174 | { 175 | dark: true, 176 | }, 177 | ); 178 | 179 | export const theme: Extension[] = [colors, syntaxHighlighting(highlightStyle)]; 180 | -------------------------------------------------------------------------------- /src/types/supabase.generated.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[] 8 | 9 | export interface Database { 10 | public: { 11 | Tables: { 12 | platform: { 13 | Row: { 14 | created_at: string 15 | id: string 16 | max_project_page_per_user: number 17 | max_project_row_per_user: number 18 | name: string 19 | } 20 | Insert: { 21 | created_at?: string 22 | id?: string 23 | max_project_page_per_user?: number 24 | max_project_row_per_user?: number 25 | name?: string 26 | } 27 | Update: { 28 | created_at?: string 29 | id?: string 30 | max_project_page_per_user?: number 31 | max_project_row_per_user?: number 32 | name?: string 33 | } 34 | Relationships: [] 35 | } 36 | project: { 37 | Row: { 38 | created_at: string 39 | description: string 40 | id: string 41 | name: string 42 | user_id: string 43 | } 44 | Insert: { 45 | created_at?: string 46 | description: string 47 | id?: string 48 | name?: string 49 | user_id?: string 50 | } 51 | Update: { 52 | created_at?: string 53 | description?: string 54 | id?: string 55 | name?: string 56 | user_id?: string 57 | } 58 | Relationships: [] 59 | } 60 | project_page: { 61 | Row: { 62 | content: Json 63 | created_at: string 64 | description: string | null 65 | id: string 66 | name: string 67 | project_id: string 68 | type: string 69 | user_id: string 70 | } 71 | Insert: { 72 | content: Json 73 | created_at?: string 74 | description?: string | null 75 | id?: string 76 | name: string 77 | project_id: string 78 | type: string 79 | user_id?: string 80 | } 81 | Update: { 82 | content?: Json 83 | created_at?: string 84 | description?: string | null 85 | id?: string 86 | name?: string 87 | project_id?: string 88 | type?: string 89 | user_id?: string 90 | } 91 | Relationships: [ 92 | { 93 | foreignKeyName: "project_page_project_id_fkey" 94 | columns: ["project_id"] 95 | referencedRelation: "project" 96 | referencedColumns: ["id"] 97 | }, 98 | { 99 | foreignKeyName: "project_page_project_id_fkey" 100 | columns: ["project_id"] 101 | referencedRelation: "project_view" 102 | referencedColumns: ["id"] 103 | } 104 | ] 105 | } 106 | } 107 | Views: { 108 | project_page_view: { 109 | Row: { 110 | content: Json | null 111 | created_at: string | null 112 | description: string | null 113 | id: string | null 114 | name: string | null 115 | owner: boolean | null 116 | project_id: string | null 117 | type: string | null 118 | user_id: string | null 119 | } 120 | Insert: { 121 | content?: Json | null 122 | created_at?: string | null 123 | description?: string | null 124 | id?: string | null 125 | name?: string | null 126 | owner?: never 127 | project_id?: string | null 128 | type?: string | null 129 | user_id?: string | null 130 | } 131 | Update: { 132 | content?: Json | null 133 | created_at?: string | null 134 | description?: string | null 135 | id?: string | null 136 | name?: string | null 137 | owner?: never 138 | project_id?: string | null 139 | type?: string | null 140 | user_id?: string | null 141 | } 142 | Relationships: [ 143 | { 144 | foreignKeyName: "project_page_project_id_fkey" 145 | columns: ["project_id"] 146 | referencedRelation: "project" 147 | referencedColumns: ["id"] 148 | }, 149 | { 150 | foreignKeyName: "project_page_project_id_fkey" 151 | columns: ["project_id"] 152 | referencedRelation: "project_view" 153 | referencedColumns: ["id"] 154 | } 155 | ] 156 | } 157 | project_view: { 158 | Row: { 159 | created_at: string | null 160 | description: string | null 161 | id: string | null 162 | name: string | null 163 | owner: boolean | null 164 | user_id: string | null 165 | } 166 | Insert: { 167 | created_at?: string | null 168 | description?: string | null 169 | id?: string | null 170 | name?: string | null 171 | owner?: never 172 | user_id?: string | null 173 | } 174 | Update: { 175 | created_at?: string | null 176 | description?: string | null 177 | id?: string | null 178 | name?: string | null 179 | owner?: never 180 | user_id?: string | null 181 | } 182 | Relationships: [] 183 | } 184 | } 185 | Functions: { 186 | get_platform_limits: { 187 | Args: Record 188 | Returns: { 189 | created_at: string 190 | id: string 191 | max_project_page_per_user: number 192 | max_project_row_per_user: number 193 | name: string 194 | } 195 | } 196 | get_user_project_page_rows: { 197 | Args: { 198 | project_id: string 199 | } 200 | Returns: number 201 | } 202 | get_user_project_rows: { 203 | Args: { 204 | user_id: string 205 | } 206 | Returns: number 207 | } 208 | is_user_same_as_auth_user: { 209 | Args: { 210 | user_id: string 211 | } 212 | Returns: boolean 213 | } 214 | } 215 | Enums: { 216 | [_ in never]: never 217 | } 218 | CompositeTypes: { 219 | [_ in never]: never 220 | } 221 | } 222 | } 223 | 224 | -------------------------------------------------------------------------------- /src/components/Projects/ProjectEditor/ProjectEditorToolbar/ProjectEditorToolbar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuPortal, 7 | DropdownMenuTrigger, 8 | IconButton, 9 | } from "@codeui/kit"; 10 | import { CogIcon } from "../../../../icons/CogIcon"; 11 | import { TrashIcon } from "../../../../icons/TrashIcon"; 12 | import { ProjectEditorPageSettingsDialog } from "../ProjectEditorPageSettingsDialog/ProjectEditorPageSettingsDialog"; 13 | import { ConfirmDialog } from "../../../../ui/ConfirmDialog/ConfirmDialog"; 14 | import { createSignal, Match, Show, Switch } from "solid-js"; 15 | import { deleteProjectPage } from "../../../../core/services/projects"; 16 | import { createControlledDialog } from "../../../../core/utils/controlledDialog"; 17 | import { provideState } from "statebuilder"; 18 | import { EditorState } from "../editorState"; 19 | import { PreviewState } from "../previewState"; 20 | import { 21 | SegmentedControl, 22 | SegmentedControlItem, 23 | } from "../../../../ui/SegmentedControl/SegmentedControl"; 24 | import { CodeIcon } from "../../../../icons/CodeIcon"; 25 | import { PresentationChart } from "../../../../icons/PresentationChart"; 26 | import { DiagramActionToolbar } from "./DiagramActionToolbar"; 27 | import { PageActionToolbar } from "./PageActionToolbar"; 28 | import { createBreakpoints } from "../../../../core/utils/breakpoint"; 29 | import { As } from "@kobalte/core"; 30 | import { EllipsisIcon } from "../../../../icons/EllipsisIcon"; 31 | import { ProjectEditorShowOnEditable } from "../ProjectEditorShowOnEditable/ProjectEditorShowOnEditable"; 32 | 33 | export function ProjectEditorToolbar() { 34 | const breakpoints = createBreakpoints(); 35 | const editorState = provideState(EditorState); 36 | 37 | const controlledDialog = createControlledDialog(); 38 | 39 | const onEditDetails = () => { 40 | controlledDialog(ProjectEditorPageSettingsDialog, { 41 | projectPage: editorState.selectedPage()!, 42 | onSave: (data) => editorState.actions.updateProjectSettings(data), 43 | }); 44 | }; 45 | 46 | const onDelete = () => { 47 | controlledDialog(ConfirmDialog, (openChange) => { 48 | const [loading, setLoading] = createSignal(false); 49 | return { 50 | title: "Delete page", 51 | message: "The action is not reversible.", 52 | onConfirm: () => { 53 | setLoading(true); 54 | const id = editorState.selectedPage()!.id; 55 | deleteProjectPage(editorState.selectedPage()!.id) 56 | .then(() => setLoading(false)) 57 | .then(() => editorState.actions.removePage(id)) 58 | .finally(() => openChange(false)); 59 | }, 60 | closeOnConfirm: false, 61 | loading: loading, 62 | actionType: "danger" as const, 63 | }; 64 | }); 65 | }; 66 | 67 | return ( 68 | 69 | 70 | 71 | 72 | 73 | 74 | 81 | 82 | 83 | 84 | 85 | 86 | } 88 | onClick={onEditDetails} 89 | > 90 | Edit details 91 | 92 | } 94 | onClick={onDelete} 95 | > 96 | Delete 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | } 113 | onClick={onEditDetails} 114 | > 115 | Edit details 116 | 117 | } 123 | onClick={onDelete} 124 | > 125 | Delete 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | editorState.actions.setPreviewMode(value)} 144 | value={editorState.get.previewMode} 145 | > 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | ); 165 | } 166 | --------------------------------------------------------------------------------
{propsWithDefault.message}
File extension
Scale