From 5440ad4d84a08b9f258295ecd8835b15b813707a Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 10 Dec 2024 17:39:22 +0100 Subject: [PATCH] feat: new setup for lifetime (#283) --- web/app/actions.ts | 3 +- web/app/api/cron/redeploy/route.ts | 87 +++ web/app/api/redeploy/route.ts | 63 ++ web/app/dashboard/lifetime/action.ts | 172 +++++ .../dashboard/lifetime/automated-setup.tsx | 636 ++++++++++++++++++ .../lifetime/components/plugin-setup.tsx | 38 ++ web/app/dashboard/lifetime/legacy-setup.tsx | 159 +++++ web/app/dashboard/lifetime/page.tsx | 224 +++--- web/components/ui/form.tsx | 177 +++++ web/components/ui/tabs.tsx | 54 ++ web/drizzle/0008_woozy_viper.sql | 9 + web/drizzle/0009_fluffy_electro.sql | 1 + web/drizzle/0010_certain_groot.sql | 10 + web/drizzle/meta/0008_snapshot.json | 174 +++++ web/drizzle/meta/0009_snapshot.json | 120 ++++ web/drizzle/meta/0010_snapshot.json | 180 +++++ web/drizzle/meta/_journal.json | 21 + web/drizzle/schema.ts | 14 + web/package.json | 5 + web/pnpm-lock.yaml | 138 ++++ web/tsconfig.json | 3 +- web/vercel.json | 4 + 22 files changed, 2142 insertions(+), 150 deletions(-) create mode 100644 web/app/api/cron/redeploy/route.ts create mode 100644 web/app/api/redeploy/route.ts create mode 100644 web/app/dashboard/lifetime/action.ts create mode 100644 web/app/dashboard/lifetime/automated-setup.tsx create mode 100644 web/app/dashboard/lifetime/components/plugin-setup.tsx create mode 100644 web/app/dashboard/lifetime/legacy-setup.tsx create mode 100644 web/components/ui/form.tsx create mode 100644 web/components/ui/tabs.tsx create mode 100644 web/drizzle/0008_woozy_viper.sql create mode 100644 web/drizzle/0009_fluffy_electro.sql create mode 100644 web/drizzle/0010_certain_groot.sql create mode 100644 web/drizzle/meta/0008_snapshot.json create mode 100644 web/drizzle/meta/0009_snapshot.json create mode 100644 web/drizzle/meta/0010_snapshot.json diff --git a/web/app/actions.ts b/web/app/actions.ts index c059d40c..9bf4b578 100644 --- a/web/app/actions.ts +++ b/web/app/actions.ts @@ -1,5 +1,5 @@ "use server"; -import { auth, clerkClient } from "@clerk/nextjs/server"; +import { auth } from "@clerk/nextjs/server"; import { Unkey } from "@unkey/api"; import { db, UserUsageTable as UserUsageTableImport } from "@/drizzle/schema"; import { eq } from "drizzle-orm"; @@ -33,7 +33,6 @@ export async function createLicenseKeyFromUserId(userId: string) { const name = "my api key"; const unkey = new Unkey({ token }); - console.log("Creating Unkey license key"); const key = await unkey.keys.create({ name: name, diff --git a/web/app/api/cron/redeploy/route.ts b/web/app/api/cron/redeploy/route.ts new file mode 100644 index 00000000..4c4ced37 --- /dev/null +++ b/web/app/api/cron/redeploy/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { db, vercelTokens } from "@/drizzle/schema"; +import { Vercel } from "@vercel/sdk"; +import { headers } from "next/headers"; + +const CRON_SECRET = process.env.CRON_SECRET; + +export async function GET(request: Request) { + console.log("Redeploy cron job started"); + // Verify the request is from Vercel Cron + const headersList = headers(); + const authHeader = headersList.get('authorization'); + + if (authHeader !== `Bearer ${CRON_SECRET}`) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + try { + // Get all tokens from the database + const tokens = await db + .select() + .from(vercelTokens); + + console.log(`Found ${tokens.length} tokens to process`); + + const results = await Promise.allSettled( + tokens.map(async (tokenRecord) => { + try { + const vercel = new Vercel({ + bearerToken: tokenRecord.token, + }); + + if (!tokenRecord.projectId) { + console.log(`No project ID for user ${tokenRecord.userId}`); + return; + } + const repo = "file-organizer-2000"; + const org = "different-ai"; + const ref = "master"; + + // Create new deployment with correct properties + const deployment = await vercel.deployments.createDeployment({ + requestBody: { + name: `file-organizer-redeploy-${Date.now()}`, + target: "production", + project: tokenRecord.projectId, + gitSource: { + type: "github", + repo: repo, + ref: ref, + org: org, + }, + projectSettings: { + framework: "nextjs", + buildCommand: "pnpm build:self-host", + installCommand: "pnpm install", + outputDirectory: ".next", + rootDirectory: "web", + }, + }, + }); + + console.log(`Redeployed project ${tokenRecord.projectId} for user ${tokenRecord.userId}`); + return deployment; + } catch (error) { + console.error(`Failed to redeploy for user ${tokenRecord.userId}:`, error); + throw error; + } + }) + ); + + const successful = results.filter((r) => r.status === "fulfilled").length; + const failed = results.filter((r) => r.status === "rejected").length; + + return NextResponse.json({ + message: `Processed ${tokens.length} tokens`, + stats: { + total: tokens.length, + successful, + failed, + }, + }); + } catch (error) { + console.error("Error in redeploy cron:", error); + return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/web/app/api/redeploy/route.ts b/web/app/api/redeploy/route.ts new file mode 100644 index 00000000..005dadf0 --- /dev/null +++ b/web/app/api/redeploy/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { db, vercelTokens } from "@/drizzle/schema"; +import { Vercel } from "@vercel/sdk"; +import { auth } from "@clerk/nextjs/server"; +import { eq } from "drizzle-orm"; + +export async function POST() { + try { + const { userId } = auth(); + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const tokenRecord = await db + .select() + .from(vercelTokens) + .where(eq(vercelTokens.userId, userId)) + .limit(1); + + if (!tokenRecord[0] || !tokenRecord[0].projectId) { + return new NextResponse("No deployment found", { status: 404 }); + } + + const vercel = new Vercel({ + bearerToken: tokenRecord[0].token, + }); + const repo = "file-organizer-2000"; + const org = "different-ai"; + const ref = "master"; + + const deployment = await vercel.deployments.createDeployment({ + requestBody: { + name: `file-organizer-redeploy-${Date.now()}`, + target: "production", + project: tokenRecord[0].projectId, + gitSource: { + type: "github", + repo: repo, + ref: ref, + org: org, + }, + projectSettings: { + framework: "nextjs", + buildCommand: "pnpm build:self-host", + installCommand: "pnpm install", + outputDirectory: ".next", + rootDirectory: "web", + }, + }, + }); + + return NextResponse.json({ + success: true, + deploymentUrl: deployment.url, + }); + } catch (error) { + console.error("Error in redeploy:", error); + return NextResponse.json( + { error: "Failed to redeploy" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/web/app/dashboard/lifetime/action.ts b/web/app/dashboard/lifetime/action.ts new file mode 100644 index 00000000..27aaa143 --- /dev/null +++ b/web/app/dashboard/lifetime/action.ts @@ -0,0 +1,172 @@ +"use server"; +import { Vercel } from "@vercel/sdk"; +import { auth } from "@clerk/nextjs/server"; +import { db, vercelTokens } from "@/drizzle/schema"; +import { eq, exists } from "drizzle-orm"; +import { createLicenseKeyFromUserId } from "@/app/actions"; + +const GITHUB_ORG = "different-ai"; +const GITHUB_REPO = "file-organizer-2000"; +const GITHUB_BRANCH = "master"; + +type SetupProjectResult = { + success: boolean; + deploymentUrl: string; + projectId: string; + licenseKey: string; + projectUrl: string; +}; + +export async function setupProject( + vercelToken: string, + openaiKey: string +): Promise { + const { userId } = auth(); + // create an api key for the user + if (!userId) { + throw new Error("User not authenticated"); + } + const apiKey = await createLicenseKeyFromUserId(userId); + + if (!vercelToken) { + throw new Error("Vercel token is required"); + } + + if (!openaiKey) { + throw new Error("OpenAI API key is required"); + } + console.log("userId", userId); + + // Store or update the token + const existingToken = await db + .select() + .from(vercelTokens) + .where(eq(vercelTokens.userId, userId)); + + console.log("existingToken", existingToken); + + if (existingToken.length > 0) { + console.log("Updating existing token", vercelToken); + // Update existing token + await db + .update(vercelTokens) + .set({ token: vercelToken, updatedAt: new Date() }) + .where(eq(vercelTokens.userId, userId)); + } else { + console.log("Inserting new token"); + // Insert new token + await db.insert(vercelTokens).values({ + userId, + token: vercelToken, + }); + } + + const vercel = new Vercel({ + bearerToken: vercelToken, + }); + + console.log("Starting setupProject process"); + const uniqueProjectName = `file-organizer-${Math.random() + .toString(36) + .substring(2, 15)}`; + + const projectUrl = `https://${uniqueProjectName}.vercel.app`; + + console.log("apiKey", apiKey.key.key); + try { + // Create project with required settings + console.log("Creating new project..."); + const createProjectResponse = await vercel.projects.createProject({ + requestBody: { + name: uniqueProjectName, + rootDirectory: "web", + publicSource: true, + framework: "nextjs", + buildCommand: "pnpm build:self-host", + installCommand: "pnpm install", + outputDirectory: ".next", + environmentVariables: [ + { + key: "SOLO_API_KEY", + value: apiKey.key.key, + type: "plain", + target: "production", + }, + { + key: "OPENAI_API_KEY", + type: "plain", + value: openaiKey, + target: "production", + }, + ], + }, + }); + console.log(`✅ Project created successfully: ${createProjectResponse.id}`); + + // Create deployment with project settings + console.log("Creating deployment..."); + const deploymentResponse = await vercel.deployments.createDeployment({ + requestBody: { + name: uniqueProjectName, + target: "production", + gitSource: { + type: "github", + repo: GITHUB_REPO, + ref: GITHUB_BRANCH, + org: GITHUB_ORG, + }, + projectSettings: { + framework: "nextjs", + buildCommand: "pnpm build:self-host", + installCommand: "pnpm install", + outputDirectory: ".next", + rootDirectory: "web", + }, + }, + }); + + // Update token record with project details and URL + await db + .update(vercelTokens) + .set({ + projectId: createProjectResponse.id, + deploymentUrl: deploymentResponse.url, + projectUrl, + updatedAt: new Date(), + }) + .where(eq(vercelTokens.userId, userId)); + + return { + success: true, + deploymentUrl: deploymentResponse.url, + projectId: createProjectResponse.id, + licenseKey: apiKey.key.key, + projectUrl, + }; + } catch (error: any) { + console.error("❌ Error in setupProject:", error); + throw error; + } +} + +// Helper function to get user's Vercel deployment info +export async function getVercelDeployment() { + const { userId } = auth(); + if (!userId) { + throw new Error("User not authenticated"); + } + + const tokenRecord = await db + .select() + .from(vercelTokens) + .where(eq(vercelTokens.userId, userId)) + .limit(1); + + return tokenRecord[0] + ? { + deploymentUrl: tokenRecord[0].deploymentUrl, + projectId: tokenRecord[0].projectId, + projectUrl: tokenRecord[0].projectUrl, + } + : null; +} diff --git a/web/app/dashboard/lifetime/automated-setup.tsx b/web/app/dashboard/lifetime/automated-setup.tsx new file mode 100644 index 00000000..6a2235ec --- /dev/null +++ b/web/app/dashboard/lifetime/automated-setup.tsx @@ -0,0 +1,636 @@ +"use client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useState, useEffect } from "react"; +import { setupProject, getVercelDeployment } from "./action"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CheckCircle2, ChevronRight, ExternalLink, Clock, Loader2, RefreshCw, AlertCircle } from "lucide-react"; +import { toast, Toaster } from "react-hot-toast"; + +const formSchema = z.object({ + vercelToken: z.string().min(1, "Vercel token is required").trim(), + openaiKey: z + .string() + .min(1, "OpenAI API key is required") + .trim() + // must start with sk- simple + .regex(/^sk-/, "OpenAI API key must start with 'sk-'"), +}); + +type FormValues = z.infer; + +export function AutomatedSetup() { + const [currentStep, setCurrentStep] = useState(1); + const [deploymentStatus, setDeploymentStatus] = useState({ + isDeploying: false, + error: null, + deploymentUrl: null, + projectUrl: null, + licenseKey: null as string | null, + }); + const [existingDeployment, setExistingDeployment] = useState<{ + deploymentUrl: string; + projectId: string; + projectUrl: string; + } | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [isRedeploying, setIsRedeploying] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + vercelToken: "", + openaiKey: "", + }, + }); + + useEffect(() => { + checkExistingDeployment(); + }, []); + + const checkExistingDeployment = async () => { + try { + const deployment = await getVercelDeployment(); + setExistingDeployment(deployment); + } catch (error) { + console.error("Failed to fetch deployment:", error); + } finally { + setIsLoading(false); + } + }; + + const handleRedeploy = async () => { + if (!existingDeployment?.projectId) return; + + setIsRedeploying(true); + try { + const response = await fetch("/api/redeploy", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to trigger redeployment"); + } + + toast.custom((t) => ( +
+

Redeployment triggered

+

+ Your instance is being updated. This may take a few minutes. +

+
+ ), { duration: 5000 }); + } catch (error) { + console.error("Redeployment failed:", error); + toast.custom((t) => ( +
+

Redeployment failed

+

+ Please try again later or contact support. +

+
+ ), { duration: 5000 }); + } finally { + setIsRedeploying(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (existingDeployment) { + return ( +
+ + + + Deployment Status +
+ + +
+
+
+ + {/* Project URL */} +
+

Project URL

+
+ + {existingDeployment.projectUrl} + + +
+
+ + {/* Actions */} +
+

Actions

+
+ +
+
+ + {/* Status Information */} +
+
+ +
+

Automatic Updates

+

+ Your instance is automatically updated daily at midnight UTC. You can also + manually trigger an update using the redeploy button above. +

+
+
+
+
+
+ + {/* Plugin Setup Reminder */} + + + Plugin Setup + + +
+

+ Make sure your Obsidian plugin is configured with these settings: +

+
+
+ Self-Hosting URL + + {existingDeployment.projectUrl} + +
+
+ +
+
+
+
+ ); + } + + const handleDeploy = async (values: FormValues) => { + setDeploymentStatus({ + isDeploying: true, + error: null, + deploymentUrl: null, + licenseKey: null, + projectUrl: null, + }); + + try { + const result = await setupProject( + values.vercelToken.trim(), + values.openaiKey.trim() + ); + setDeploymentStatus({ + isDeploying: false, + error: null, + deploymentUrl: result.deploymentUrl, + licenseKey: result.licenseKey, + projectUrl: result.projectUrl, + }); + setCurrentStep(3); // Move to final step after successful deployment + } catch (error: any) { + setDeploymentStatus({ + isDeploying: false, + error: error.message, + deploymentUrl: null, + licenseKey: null, + projectUrl: null, + }); + } + }; + console.log(form.formState, "form state"); + + const TroubleshootingSection = () => ( +
+
+ +

+ Troubleshooting Guide +

+ +
+
+
+
Common Issues:
+
    +
  • + Deployment Stuck: If your deployment seems stuck, check the build logs on Vercel by clicking on the deployment URL and navigating to the "Runtime Logs" tab. +
  • +
  • + Invalid Project URL: Make sure you're using the production URL from your Vercel deployment (usually ends with .vercel.app unless you've configured a custom domain). +
  • +
  • + License Key Not Working: Verify that you've copied the entire license key without any extra spaces. +
  • +
+
+ +
+

+ If you continue experiencing issues, please email{" "} + + alex@fileorganizer2000.com + {" "} + with: +

+
    +
  • Your deployment URL
  • +
  • Screenshots of any error messages
  • +
  • Steps you've tried so far
  • +
+
+
+
+
+ ); + + const steps = [ + { + number: 1, + title: "Get Your Vercel Token", + isCompleted: currentStep > 1, + isCurrent: currentStep === 1, + content: ( +
+
    +
  1. + Create a free Vercel account if you don't have one at{" "} + + vercel.com/signup + +
  2. +
  3. Go to your account settings → Tokens
  4. +
  5. + Create a new token with: +
      +
    • Scope: Select your personal account scope
    • +
    • Expiration: Never
    • +
    +
  6. +
+ +
+ ), + }, + { + number: 2, + title: "Deploy Your Instance", + isCompleted: currentStep > 2, + isCurrent: currentStep === 2, + content: ( +
+ + ( + + Vercel Token + + + + + + )} + /> + + ( + + OpenAI API Key + + + + +

+ Get your API key from{" "} + + OpenAI Dashboard + +

+
+ )} + /> + + + + + ), + }, + { + number: 3, + title: "Configure Plugin", + isCompleted: false, + isCurrent: currentStep === 3, + content: ( +
+ {deploymentStatus.deploymentUrl && ( + + + Deployment Status + + +
+

+ 1. Click the link below to check your deployment status: +

+ + + View Deployment Status + +
+
+

+ + Wait for the deployment to complete (usually takes 2-3 minutes) +

+
+
+
+ )} + + {deploymentStatus.licenseKey && ( + + + Your License Key + + +
+ + {deploymentStatus.licenseKey} + + +
+
+
+ )} + +
+

Next Steps:

+
    +
  1. + After deployment is complete, copy your project URL from the Vercel dashboard + {deploymentStatus.projectUrl && ( +
    + + {deploymentStatus.projectUrl} + + +
    + )} +
  2. +
  3. + Install the Obsidian plugin: + +
  4. +
  5. Open Obsidian settings and go to File Organizer settings
  6. +
  7. Click on Advanced and enable "Self-Hosting" toggle
  8. +
  9. Enter your project URL and license key in the settings
  10. +
  11. Click "Activate" to complete the setup
  12. +
+
+ + +
+ ), + }, + ]; + + return ( +
+ + + + + Self-Hosting Setup + +

+ Follow these steps to set up your own instance +

+
+ +
+ {steps.map((step, index) => ( +
+ {/* Connection line between steps */} + {index < steps.length - 1 && ( +
+ )} + +
+
+
+ {step.isCompleted ? ( + + ) : ( + + {step.number} + + )} +
+

+ {step.title} +

+
+ + {step.isCurrent && ( +
+
+ {step.content} +
+
+ )} +
+
+ ))} +
+ + {/* Error message */} + {deploymentStatus.error && ( +
+
+
+
+ + + + + +
+
+

+ Deployment Error +

+

+ {deploymentStatus.error} +

+
+
+
+
+ )} + + +
+ ); +} diff --git a/web/app/dashboard/lifetime/components/plugin-setup.tsx b/web/app/dashboard/lifetime/components/plugin-setup.tsx new file mode 100644 index 00000000..dd3b33fb --- /dev/null +++ b/web/app/dashboard/lifetime/components/plugin-setup.tsx @@ -0,0 +1,38 @@ +import { Button } from "@/components/ui/button"; + +import { Card, CardTitle, CardHeader, CardContent } from "@/components/ui/card"; + +export function PluginSetup({ projectUrl }) { + return ( + + + Plugin Setup + + +
+

+ Make sure your Obsidian plugin is configured with these settings: +

+
+
+ Self-Hosting URL + + {projectUrl} + +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/web/app/dashboard/lifetime/legacy-setup.tsx b/web/app/dashboard/lifetime/legacy-setup.tsx new file mode 100644 index 00000000..6f8eecb6 --- /dev/null +++ b/web/app/dashboard/lifetime/legacy-setup.tsx @@ -0,0 +1,159 @@ +"use client"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { LicenseForm } from "@/app/components/license-form"; + +export function LegacySetup() { + return ( +
+
+ + +
+ +
+
+
+ + + + Generate License + + + + + +
+ + + +
    +
  1. + Deploy your instance: +
    + + Deploy with Vercel + + or + + Deploy to Render + +
    +
      +
    • You'll need to sign up/in on Vercel and GitHub.
    • +
    • + During deployment, enter your{" "} + + OpenAI API Key + + . Make sure you have added credits to your account. +
    • +
    • + For the SOLO_API_KEY, use the license key generated in the + "Generate License" section below. +
    • +
    +

    + Note on Render: Deployment on Render costs + $7/month. However, Render provides automatic updates for + your installation, ensuring you always have the latest + version. +

    +
  2. + +
  3. + Configure plugin: +
      +
    • Go to Obsidian settings
    • +
    • Enable "Self-Hosting" toggle
    • +
    • Enter your Vercel URL
    • +
    +
  4. + +
  5. + Activate license: +
      +
    • Enter license key in plugin settings
    • +
    • Click "Activate" button
    • +
    +
  6. + +
  7. + Need help? + +
  8. +
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/web/app/dashboard/lifetime/page.tsx b/web/app/dashboard/lifetime/page.tsx index 02b64145..c130cb68 100644 --- a/web/app/dashboard/lifetime/page.tsx +++ b/web/app/dashboard/lifetime/page.tsx @@ -1,169 +1,99 @@ -import { Button } from "@/components/ui/button"; -import { UserButton } from "@clerk/nextjs"; -import { LicenseForm } from "@/app/components/license-form"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; - -export default async function LifetimeAccessPage() { - if (process.env.ENABLE_USER_MANAGEMENT !== "true") { - return ( -
- User management is disabled -
- ); - } +"use client"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { AutomatedSetup } from "./automated-setup"; +import { LegacySetup } from "./legacy-setup"; +import { InfoIcon, BookOpenIcon } from "lucide-react"; +export default function LifetimeAccessPage() { return (

Lifetime Access Setup

-
-
- - -
- -
-
-
- - - - Generate License - - - - - -
+ + + Recommended Setup + Legacy Setup + - - -
    -
  1. - Deploy your instance: -
    - - Deploy with Vercel - - or - - Deploy to Render - + +
    +
    +
    +
    +
    -
      -
    • You'll need to sign up/in on Vercel and GitHub.
    • +

      Before you start

      +
    +
    +
    1. - During deployment, enter your{" "} + Create a free Vercel account if you don't have one at{" "} - OpenAI API Key + vercel.com/signup - . Make sure you have added credits to your account.
    2. +
    3. Go to your account settings → Tokens
    4. - For the SOLO_API_KEY, use the license key generated in the - "Generate License" section below. + Create a new token with: +
        +
      • Scope: Select your personal account scope
      • +
      • Expiration: Never
      • +
    5. - -

      - Note on Render: Deployment on Render costs - $7/month. However, Render provides automatic updates for - your installation, ensuring you always have the latest - version. -

      - -
    6. - Configure plugin: -
        -
      • Go to Obsidian settings
      • -
      • Enable "Self-Hosting" toggle
      • -
      • Enter your Vercel URL
      • -
      -
    7. -
    8. - Activate license: -
        -
      • Enter license key in plugin settings
      • -
      • Click "Activate" button
      • -
      -
    9. -
    10. - Need help? - -
    11. -
    - - -
    +
+
+
-
- - - Get the Plugin - - - - - -

- Requires Obsidian app. -

-
-
+
+
+
+ +
+

Visual Setup Guide

+
+
+

+ Follow our step-by-step visual guide for setting up your lifetime access: +

+ + View the Tutorial + +
+
+
+ + - - - Need Help? - - - - Check out our FAQ - - - - + +
+
+
+ +
+

Legacy Setup

+
+
+

+ Only use this method if you're experiencing issues with the + recommended setup. This method requires manual configuration and + might need additional troubleshooting. +

+
+
+ +
+ ); diff --git a/web/components/ui/form.tsx b/web/components/ui/form.tsx new file mode 100644 index 00000000..d9c24af1 --- /dev/null +++ b/web/components/ui/form.tsx @@ -0,0 +1,177 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +