├── .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 | 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 | 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 | 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 | 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 | 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 | 26 | 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 | 38 | 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 | 47 | 48 | 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 effectivness
and 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 | 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 | 90 | 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 |