feat: add telemetry in electron app & UI improvements
This commit is contained in:
parent
692d90e72e
commit
5f191ebf11
40 changed files with 373 additions and 1081 deletions
|
|
@ -227,7 +227,7 @@ You can also set the following environment variables to customize the image gene
|
|||
|
||||
You can disable anonymous telemetry using the following environment variable:
|
||||
|
||||
- DISABLE_ANONYMOUS_TELEMETRY=[true/false]: Set this to **true** to disable anonymous telemetry.
|
||||
- DISABLE_ANONYMOUS_TRACKING=[true/false]: Set this to **true** to disable anonymous telemetry.
|
||||
|
||||
> Note: You can freely choose both the LLM (text generation) and the image provider. Supported image providers: **dall-e-3**, **gpt-image-1.5** (OpenAI), **gemini_flash**, **nanobanana_pro** (Google), **pexels**, **pixabay**, and **comfyui** (self-hosted).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { app, ipcMain } from "electron";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getUserConfig } from "../utils";
|
||||
|
||||
export function setupApiHandlers() {
|
||||
// Handler for can-change-keys API
|
||||
|
|
@ -26,15 +27,19 @@ export function setupApiHandlers() {
|
|||
|
||||
const keyFromEnv = process.env.OPENAI_API_KEY || "";
|
||||
const hasKey = Boolean((keyFromFile || keyFromEnv).trim());
|
||||
|
||||
|
||||
return { hasKey };
|
||||
});
|
||||
|
||||
// Handler for telemetry-status API
|
||||
// Reads persisted user config so runtime toggles from the settings page
|
||||
// are picked up immediately without requiring an app restart.
|
||||
ipcMain.handle("api:telemetry-status", async () => {
|
||||
const isDisabled = process.env.DISABLE_ANONYMOUS_TELEMETRY === 'true' || process.env.DISABLE_ANONYMOUS_TELEMETRY === 'True';
|
||||
const telemetryEnabled = !isDisabled;
|
||||
return { telemetryEnabled };
|
||||
const cfg = getUserConfig();
|
||||
const fromConfig = cfg.DISABLE_ANONYMOUS_TRACKING;
|
||||
const fromEnv = process.env.DISABLE_ANONYMOUS_TRACKING;
|
||||
const raw = fromConfig ?? fromEnv ?? "";
|
||||
const isDisabled = raw === "true" || raw === "True";
|
||||
return { telemetryEnabled: !isDisabled };
|
||||
});
|
||||
|
||||
// Handler for save-layout API
|
||||
|
|
@ -100,36 +105,36 @@ export function setupApiHandlers() {
|
|||
// In production, use resources/nextjs/presentation-templates
|
||||
const baseDir = app.getAppPath();
|
||||
const isDev = !app.isPackaged;
|
||||
const templatesPath = isDev
|
||||
const templatesPath = isDev
|
||||
? path.join(baseDir, "servers", "nextjs", "presentation-templates")
|
||||
: path.join(baseDir, "resources", "nextjs", "presentation-templates");
|
||||
|
||||
|
||||
if (!fs.existsSync(templatesPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const items = fs.readdirSync(templatesPath, { withFileTypes: true });
|
||||
const templateDirectories = items
|
||||
.filter(item => item.isDirectory())
|
||||
.map(dir => dir.name);
|
||||
|
||||
const allLayouts: Array<{templateName: string; templateID: string; files: string[]; settings: any }> = [];
|
||||
|
||||
|
||||
const allLayouts: Array<{ templateName: string; templateID: string; files: string[]; settings: any }> = [];
|
||||
|
||||
// Scan each template directory for layout files and settings
|
||||
for (const templateName of templateDirectories) {
|
||||
try {
|
||||
const templatePath = path.join(templatesPath, templateName);
|
||||
const templateFiles = fs.readdirSync(templatePath);
|
||||
|
||||
|
||||
// Filter for .tsx files and exclude any non-layout files
|
||||
const layoutFiles = templateFiles.filter(file =>
|
||||
file.endsWith('.tsx') &&
|
||||
!file.startsWith('.') &&
|
||||
const layoutFiles = templateFiles.filter(file =>
|
||||
file.endsWith('.tsx') &&
|
||||
!file.startsWith('.') &&
|
||||
!file.includes('.test.') &&
|
||||
!file.includes('.spec.') &&
|
||||
file !== 'settings.json'
|
||||
);
|
||||
|
||||
|
||||
// Read settings.json if it exists
|
||||
let settings: any = null;
|
||||
const settingsPath = path.join(templatePath, 'settings.json');
|
||||
|
|
@ -145,7 +150,7 @@ export function setupApiHandlers() {
|
|||
default: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (layoutFiles.length > 0) {
|
||||
allLayouts.push({
|
||||
templateName: templateName,
|
||||
|
|
@ -159,7 +164,7 @@ export function setupApiHandlers() {
|
|||
// Continue with other templates even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return allLayouts;
|
||||
} catch (error) {
|
||||
console.error("Error reading templates:", error);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ contextBridge.exposeInMainWorld('env', {
|
|||
NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL || '',
|
||||
TEMP_DIRECTORY: process.env.TEMP_DIRECTORY || '',
|
||||
NEXT_PUBLIC_USER_CONFIG_PATH: process.env.NEXT_PUBLIC_USER_CONFIG_PATH || '',
|
||||
APP_VERSION: process.env.APP_VERSION || '',
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ export function getUserConfig(): UserConfig {
|
|||
}
|
||||
|
||||
export function setupEnv(fastApiPort: number, nextjsPort: number) {
|
||||
const { app } = require('electron');
|
||||
process.env.APP_VERSION = app.getVersion();
|
||||
process.env.NEXT_PUBLIC_FAST_API = `${localhost}:${fastApiPort}`;
|
||||
process.env.TEMP_DIRECTORY = tempDir;
|
||||
process.env.NEXT_PUBLIC_USER_CONFIG_PATH = userConfigPath;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { LayoutDashboard, Star, Brain, Settings, Palette } from "lucide-react";
|
|||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ const DashboardSidebar = () => {
|
|||
>
|
||||
<div>
|
||||
|
||||
<div onClick={() => router.push("/dashboard")} className="flex items-center pb-6 border-b border-[#E1E1E5] gap-2 ">
|
||||
<div onClick={() => { trackEvent(MixpanelEvent.Sidebar_Navigation_Clicked, { target: '/dashboard' }); router.push("/dashboard"); }} className="flex items-center pb-6 border-b border-[#E1E1E5] gap-2 ">
|
||||
<div className="bg-[#7C51F8] rounded-full cursor-pointer p-1 flex justify-center items-center mx-auto">
|
||||
<img src="/logo-with-bg.png" alt="Presenton logo" className="h-[40px] object-contain w-full" />
|
||||
</div>
|
||||
|
|
@ -49,6 +50,7 @@ const DashboardSidebar = () => {
|
|||
<Link
|
||||
prefetch={false}
|
||||
href={`/dashboard`}
|
||||
onClick={() => trackEvent(MixpanelEvent.Sidebar_Navigation_Clicked, { target: '/dashboard' })}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/dashboard" ? "" : "ring-transparent",
|
||||
|
|
@ -62,6 +64,7 @@ const DashboardSidebar = () => {
|
|||
<Link
|
||||
prefetch={false}
|
||||
href={`/templates`}
|
||||
onClick={() => trackEvent(MixpanelEvent.Sidebar_Navigation_Clicked, { target: '/templates' })}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/templates" ? "" : "ring-transparent",
|
||||
|
|
@ -77,6 +80,7 @@ const DashboardSidebar = () => {
|
|||
<Link
|
||||
prefetch={false}
|
||||
href={`/theme`}
|
||||
onClick={() => trackEvent(MixpanelEvent.Sidebar_Navigation_Clicked, { target: '/theme' })}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/theme" ? "" : "ring-transparent",
|
||||
|
|
@ -102,6 +106,7 @@ const DashboardSidebar = () => {
|
|||
prefetch={false}
|
||||
key={key}
|
||||
href={`/${key}`}
|
||||
onClick={() => trackEvent(MixpanelEvent.Sidebar_Navigation_Clicked, { target: `/${key}` })}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors ",
|
||||
isActive ? "" : "ring-transparent",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashbo
|
|||
import { PresentationGrid } from "@/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ const DashboardPage: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent(MixpanelEvent.Dashboard_Page_Viewed);
|
||||
const loadData = async () => {
|
||||
await fetchPresentations();
|
||||
};
|
||||
|
|
@ -59,6 +61,7 @@ const DashboardPage: React.FC = () => {
|
|||
|
||||
<Link
|
||||
href="/upload"
|
||||
onClick={() => trackEvent(MixpanelEvent.Dashboard_New_Presentation_Clicked)}
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-semibold font-syne shadow-sm hover:shadow-md"
|
||||
aria-label="Create new presentation"
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ const Header = () => {
|
|||
: "Go to your dashboard";
|
||||
|
||||
return (
|
||||
<div className="w-full sticky top-0 z-50 py-7 ">
|
||||
<div className="w-full sticky top-0 z-50 py-7 "
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #FFF 0%, rgba(255, 255, 255, 0.00) 110.67%)",
|
||||
|
||||
}}
|
||||
>
|
||||
<Wrapper className="px-5 sm:px-10 lg:px-20">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { toast } from "sonner";
|
|||
import { useFontLoader } from "@/app/(presentation-generator)/hooks/useFontLoad";
|
||||
import SlideScale from "@/app/(presentation-generator)/components/PresentationRender";
|
||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
export const PresentationCard = ({
|
||||
id,
|
||||
|
|
@ -31,6 +32,7 @@ export const PresentationCard = ({
|
|||
|
||||
const handlePreview = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
trackEvent(MixpanelEvent.Dashboard_Presentation_Opened, { presentation_id: id });
|
||||
router.push(`/presentation?id=${id}&type=standard`);
|
||||
};
|
||||
useEffect(() => {
|
||||
|
|
@ -80,6 +82,7 @@ export const PresentationCard = ({
|
|||
e.stopPropagation();
|
||||
|
||||
|
||||
trackEvent(MixpanelEvent.Dashboard_Presentation_Deleted, { presentation_id: id });
|
||||
const response = await DashboardApi.deletePresentation(id);
|
||||
|
||||
if (response) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { PlusIcon } from "@radix-ui/react-icons";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { PresentationResponse } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
interface PresentationGridProps {
|
||||
presentations: PresentationResponse[];
|
||||
|
|
@ -22,6 +23,7 @@ export const PresentationGrid = ({
|
|||
}: PresentationGridProps) => {
|
||||
const router = useRouter();
|
||||
const handleCreateNewPresentation = () => {
|
||||
trackEvent(MixpanelEvent.Dashboard_Create_New_Card_Clicked, { type });
|
||||
if (type === "slide") {
|
||||
router.push("/upload");
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { setTelemetryEnabled } from "@/utils/mixpanel";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const PrivacySettings = () => {
|
||||
const [trackingEnabled, setTrackingEnabled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
if (window.electron?.telemetryStatus) {
|
||||
const data = await window.electron.telemetryStatus();
|
||||
setTrackingEnabled(data.telemetryEnabled);
|
||||
} else {
|
||||
const res = await fetch("/api/telemetry-status");
|
||||
const data = await res.json();
|
||||
setTrackingEnabled(data.telemetryEnabled);
|
||||
}
|
||||
} catch {
|
||||
setTrackingEnabled(true);
|
||||
}
|
||||
}
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const handleTrackingToggle = async (enabled: boolean) => {
|
||||
const prev = trackingEnabled;
|
||||
setTrackingEnabled(enabled);
|
||||
setTelemetryEnabled(enabled);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (window.electron?.setUserConfig) {
|
||||
await window.electron.setUserConfig({
|
||||
DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true",
|
||||
} as any);
|
||||
} else {
|
||||
await fetch("/api/user-config", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : "true",
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setTrackingEnabled(prev);
|
||||
setTelemetryEnabled(prev ?? true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (trackingEnabled === null) {
|
||||
return (
|
||||
<div className="w-full bg-[#F9F8F8] p-7 rounded-[20px] flex items-center justify-center min-h-[200px]">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-[#5146E5]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="bg-[#F9F8F8] p-7 rounded-[20px]">
|
||||
<h4 className="text-sm font-semibold text-[#191919] mb-1">
|
||||
Anonymous Usage Tracking
|
||||
</h4>
|
||||
<p className="text-xs text-[#6B7280] mb-6 leading-relaxed max-w-lg">
|
||||
When enabled, Presenton collects anonymous usage data to help us
|
||||
understand how the app is used and improve your experience. No
|
||||
personal information or presentation content is ever collected.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 rounded-[10px] bg-white border border-[#EDEEEF] p-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tracking-toggle"
|
||||
className="text-sm font-medium text-[#191919] cursor-pointer select-none block"
|
||||
>
|
||||
{trackingEnabled ? "Tracking Enabled" : "Tracking Disabled"}
|
||||
</label>
|
||||
<p className="text-xs text-[#9CA3AF] mt-0.5">
|
||||
{trackingEnabled
|
||||
? "Anonymous usage data is being sent."
|
||||
: "No usage data is being collected."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saving && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#9CA3AF]" />
|
||||
)}
|
||||
<Switch
|
||||
id="tracking-toggle"
|
||||
checked={trackingEnabled}
|
||||
onCheckedChange={handleTrackingToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacySettings;
|
||||
|
|
@ -19,6 +19,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
|||
import SettingSideBar from "./SettingSideBar";
|
||||
import TextProvider from "./TextProvider";
|
||||
import ImageProvider from "./ImageProvider";
|
||||
import PrivacySettings from "./PrivacySettings";
|
||||
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
|
||||
|
||||
// Button state interface
|
||||
|
|
@ -35,7 +36,7 @@ const SettingsPage = () => {
|
|||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [mode, setMode] = useState<'nanobanana' | 'presenton'>('presenton')
|
||||
const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider'>('text-provider')
|
||||
const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider' | 'privacy'>('text-provider')
|
||||
const userConfigState = useSelector((state: RootState) => state.userConfig);
|
||||
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
|
||||
userConfigState.llm_config
|
||||
|
|
@ -228,7 +229,7 @@ const SettingsPage = () => {
|
|||
? llmConfig.CUSTOM_MODEL
|
||||
: textProviderKey === "codex"
|
||||
? llmConfig.CODEX_MODEL
|
||||
: "";
|
||||
: "";
|
||||
const textSummary = selectedTextModel
|
||||
? `${textProviderLabel} (${selectedTextModel})`
|
||||
: textProviderLabel;
|
||||
|
|
@ -280,6 +281,7 @@ const SettingsPage = () => {
|
|||
llmConfig={llmConfig}
|
||||
/>}
|
||||
{mode === 'presenton' && selectedProvider === 'image-provider' && <ImageProvider llmConfig={llmConfig} setLlmConfig={setLlmConfig} />}
|
||||
{selectedProvider === 'privacy' && <PrivacySettings />}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react'
|
||||
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider', setSelectedProvider: (provider: 'text-provider' | 'image-provider') => void }) => {
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider' | 'privacy', setSelectedProvider: (provider: 'text-provider' | 'image-provider' | 'privacy') => void }) => {
|
||||
return (
|
||||
<div className='w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB]'>
|
||||
<div className='w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB] flex flex-col'>
|
||||
<p className='text-xs text-black font-medium border-b mt-[3.15rem] border-[#E1E1E5] pb-3.5'>FILTER BY:</p>
|
||||
<div className='mt-6'>
|
||||
<div className='mt-6 flex-1'>
|
||||
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Mode</p>
|
||||
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
|
||||
<button className='px-3 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
|
|
@ -61,6 +63,19 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className='border-t border-[#E1E1E5] py-5 relative z-50'>
|
||||
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Other</p>
|
||||
<button
|
||||
className={`w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'privacy' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#EDEEEF]'}`}
|
||||
onClick={() => setSelectedProvider('privacy')}
|
||||
>
|
||||
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF] flex items-center justify-center bg-white'>
|
||||
<Shield className='w-3.5 h-3.5 text-[#5146E5]' />
|
||||
</div>
|
||||
<p className='text-[#191919] text-xs font-medium'>Privacy</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { Plus, Sparkles } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
const CreateCustomTemplate = () => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Templates_Build_Template_Clicked);
|
||||
router.push('/custom-template')
|
||||
}}
|
||||
className='w-full rounded-xl border border-[#EDEEEF] cursor-pointer font-syne'>
|
||||
|
|
|
|||
|
|
@ -13,19 +13,21 @@ import {
|
|||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
import CreateCustomTemplate from "./CreateCustomTemplate";
|
||||
import Link from "next/link";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
// Component for rendering custom template card with lazy-loaded previews
|
||||
export const CustomTemplateCard = React.memo(function CustomTemplateCard({ template }: { template: CustomTemplates }) {
|
||||
const router = useRouter();
|
||||
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
|
||||
const handleOpen = useCallback(() => {
|
||||
trackEvent(MixpanelEvent.Templates_Custom_Opened, { template_id: template.id, template_name: template.name });
|
||||
if (template.id.startsWith('custom-')) {
|
||||
router.push(`/template-preview?slug=${template.id}`)
|
||||
} else {
|
||||
router.push(`/template-preview?slug=custom-${template.id}`)
|
||||
}
|
||||
}
|
||||
, [router, template.id]);
|
||||
, [router, template.id, template.name]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
|
@ -163,6 +165,7 @@ const LayoutPreview = () => {
|
|||
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent(MixpanelEvent.Templates_Page_Viewed);
|
||||
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
|
||||
if (!existingScript) {
|
||||
const script = document.createElement("script");
|
||||
|
|
@ -172,7 +175,10 @@ const LayoutPreview = () => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenPreview = useCallback((id: string) => router.push(`/template-preview?slug=${id}`), [router]);
|
||||
const handleOpenPreview = useCallback((id: string) => {
|
||||
trackEvent(MixpanelEvent.Templates_Inbuilt_Opened, { template_id: id });
|
||||
router.push(`/template-preview?slug=${id}`);
|
||||
}, [router]);
|
||||
|
||||
const { nonNeoInbuilt, neoInbuilt } = useMemo(() => {
|
||||
const nonNeo: TemplateLayoutsWithSettings[] = [];
|
||||
|
|
@ -207,6 +213,7 @@ const LayoutPreview = () => {
|
|||
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
|
||||
<Link
|
||||
href="/custom-template"
|
||||
onClick={() => trackEvent(MixpanelEvent.Templates_New_Template_Clicked)}
|
||||
className="inline-flex items-center font-syne font-semibold gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
|
||||
aria-label="Create new template"
|
||||
style={{
|
||||
|
|
@ -226,7 +233,7 @@ const LayoutPreview = () => {
|
|||
<div className="l mx-auto px-6 py-8">
|
||||
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center '>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('custom')}
|
||||
onClick={() => { trackEvent(MixpanelEvent.Templates_Tab_Switched, { tab: 'custom' }); setTab('custom'); }}
|
||||
style={{
|
||||
background: tab === 'custom' ? '#F4F3FF' : 'transparent',
|
||||
color: tab === 'custom' ? '#5146E5' : '#3A3A3A'
|
||||
|
|
@ -236,7 +243,7 @@ const LayoutPreview = () => {
|
|||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('default')}
|
||||
onClick={() => { trackEvent(MixpanelEvent.Templates_Tab_Switched, { tab: 'default' }); setTab('default'); }}
|
||||
style={{
|
||||
background: tab === 'default' ? '#F4F3FF' : 'transparent',
|
||||
color: tab === 'default' ? '#5146E5' : '#3A3A3A'
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import CustomTabEmpty from './CustomTabEmpty'
|
|||
import ThemeApi from '@/app/(presentation-generator)/services/api/theme'
|
||||
import { useFontLoader } from '@/app/(presentation-generator)/hooks/useFontLoad'
|
||||
import Link from 'next/link'
|
||||
import { trackEvent, MixpanelEvent } from '@/utils/mixpanel'
|
||||
|
||||
// Fallback theme used before defaults are loaded from API (unified Theme type)
|
||||
const FALLBACK_THEME: Theme = {
|
||||
|
|
@ -124,6 +125,7 @@ const ThemePanel: React.FC = () => {
|
|||
|
||||
// Initialize theme on component mount
|
||||
useEffect(() => {
|
||||
trackEvent(MixpanelEvent.Theme_Page_Viewed)
|
||||
applyTheme(selectedTheme)
|
||||
}, [])
|
||||
|
||||
|
|
@ -256,6 +258,7 @@ const ThemePanel: React.FC = () => {
|
|||
}
|
||||
|
||||
const handleThemeSelect = (theme: Theme) => {
|
||||
trackEvent(MixpanelEvent.Theme_Selected, { theme_id: theme.id, theme_name: theme.name })
|
||||
setIsNewTheme(false)
|
||||
setSelectedTheme(theme)
|
||||
setCustomColors(theme.data.colors)
|
||||
|
|
@ -279,6 +282,7 @@ const ThemePanel: React.FC = () => {
|
|||
}
|
||||
|
||||
const handleFontSelect = (fontName: string, url: string) => {
|
||||
trackEvent(MixpanelEvent.Theme_Font_Changed, { font_name: fontName })
|
||||
setCustomFonts({ textFont: { name: fontName, url: url } })
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +290,7 @@ const ThemePanel: React.FC = () => {
|
|||
try {
|
||||
setIsLogoUploading(true)
|
||||
const uploaded = await ImagesApi.uploadImage(file)
|
||||
trackEvent(MixpanelEvent.Theme_Logo_Uploaded)
|
||||
setCustomBrandLogo(uploaded.path)
|
||||
setCustomBrandLogoId(uploaded.id)
|
||||
} catch (error: any) {
|
||||
|
|
@ -397,6 +402,7 @@ const ThemePanel: React.FC = () => {
|
|||
}
|
||||
}
|
||||
const updated = await ThemeApi.updateTheme(params)
|
||||
trackEvent(MixpanelEvent.Theme_Saved, { theme_id: updated.id, is_update: true })
|
||||
setCustomThemes(customThemes.map(t => t.id === updated.id ? updated : t))
|
||||
setSelectedTheme(updated)
|
||||
setIsSheetOpen(false)
|
||||
|
|
@ -421,6 +427,7 @@ const ThemePanel: React.FC = () => {
|
|||
}
|
||||
}
|
||||
const created = await ThemeApi.createTheme(params)
|
||||
trackEvent(MixpanelEvent.Theme_Saved, { theme_id: created.id, is_update: false })
|
||||
setCustomThemes([...customThemes, created])
|
||||
setSelectedTheme(created)
|
||||
setIsSheetOpen(false)
|
||||
|
|
@ -438,6 +445,7 @@ const ThemePanel: React.FC = () => {
|
|||
setShowColorPicker(null)
|
||||
}
|
||||
const handleDelete = async (themeId: string) => {
|
||||
trackEvent(MixpanelEvent.Theme_Deleted, { theme_id: themeId })
|
||||
await ThemeApi.deleteTheme(themeId)
|
||||
setCustomThemes(customThemes.filter(theme => theme.id !== themeId))
|
||||
toast.success("Theme deleted successfully")
|
||||
|
|
@ -446,6 +454,7 @@ const ThemePanel: React.FC = () => {
|
|||
try {
|
||||
setIsFontUploading(true)
|
||||
const { font_name, font_url } = await ThemeApi.uploadFont(fontFile)
|
||||
trackEvent(MixpanelEvent.Theme_Custom_Font_Uploaded, { font_name })
|
||||
setCustomFonts({
|
||||
textFont: {
|
||||
name: font_name,
|
||||
|
|
@ -862,6 +871,7 @@ const ThemePanel: React.FC = () => {
|
|||
</h3>
|
||||
<Link
|
||||
href="/theme?tab=new-theme"
|
||||
onClick={() => trackEvent(MixpanelEvent.Theme_New_Theme_Clicked)}
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-semibold font-syne shadow-sm hover:shadow-md"
|
||||
aria-label="Create new theme"
|
||||
style={{
|
||||
|
|
@ -878,7 +888,7 @@ const ThemePanel: React.FC = () => {
|
|||
{/* Tabs */}
|
||||
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center '>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('custom')}
|
||||
onClick={() => { trackEvent(MixpanelEvent.Theme_Tab_Switched, { tab: 'custom' }); setTab('custom'); }}
|
||||
style={{
|
||||
background: tab === 'custom' ? '#F4F3FF' : 'transparent'
|
||||
}}
|
||||
|
|
@ -887,7 +897,7 @@ const ThemePanel: React.FC = () => {
|
|||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('default')}
|
||||
onClick={() => { trackEvent(MixpanelEvent.Theme_Tab_Switched, { tab: 'default' }); setTab('default'); }}
|
||||
style={{
|
||||
background: tab === 'default' ? '#F4F3FF' : 'transparent'
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import Wrapper from "@/components/Wrapper";
|
||||
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { PresentationGrid } from "@/app/(presentation-generator)/dashboard/components/PresentationGrid";
|
||||
|
||||
|
||||
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [presentations, setPresentations] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await fetchPresentations();
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const fetchPresentations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await DashboardApi.getPresentations();
|
||||
data.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
);
|
||||
setPresentations(data);
|
||||
} catch (err) {
|
||||
setError(null);
|
||||
setPresentations([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removePresentation = (presentationId: string) => {
|
||||
setPresentations((prev: any) =>
|
||||
prev ? prev.filter((p: any) => p.id !== presentationId) : []
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#E9E8F8]">
|
||||
<Header />
|
||||
<Wrapper>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-roboto font-medium mb-6">
|
||||
Slide Presentation
|
||||
</h2>
|
||||
<PresentationGrid
|
||||
presentations={presentations}
|
||||
type="slide"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onPresentationDeleted={removePresentation}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
export const EmptyState = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] bg-white/50 rounded-lg">
|
||||
<div className="mb-4">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M42 14.4V33.6C42 40.8 38 44.8 30.8 44.8H17.2C10 44.8 6 40.8 6 33.6V14.4C6 7.2 10 3.2 17.2 3.2H30.8C38 3.2 42 7.2 42 14.4Z" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.96002 16.4188H41.04" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.04 3.21875V15.1388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M28.96 3.21875V14.2388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-[#101828] text-lg font-roboto font-medium mb-1">
|
||||
You don't have any presentations yet.
|
||||
</h3>
|
||||
<p className="text-[#667085] text-base font-roboto">
|
||||
Start creating the first one.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Wrapper from "@/components/Wrapper";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import BackBtn from "@/components/BackBtn";
|
||||
import { usePathname } from "next/navigation";
|
||||
import HeaderNav from "@/app/(presentation-generator)/components/HeaderNab";
|
||||
import { Layout, FilePlus2 } from "lucide-react";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
const Header = () => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="bg-[#5146E5] w-full shadow-lg sticky top-0 z-50">
|
||||
<Wrapper>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{(pathname !== "/upload" && pathname !== "/dashboard") && <BackBtn />}
|
||||
<Link href="/dashboard" onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
|
||||
<img
|
||||
src="/logo-white.png"
|
||||
alt="Presentation logo"
|
||||
className="h-16"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/custom-template"
|
||||
prefetch={false}
|
||||
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/custom-template" })}
|
||||
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
|
||||
role="menuitem"
|
||||
>
|
||||
<FilePlus2 className="w-5 h-5" />
|
||||
<span className="text-sm font-medium font-inter">Create Template</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/templates"
|
||||
prefetch={false}
|
||||
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/templates" })}
|
||||
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
|
||||
role="menuitem"
|
||||
>
|
||||
<Layout className="w-5 h-5" />
|
||||
<span className="text-sm font-medium font-inter">Templates</span>
|
||||
</Link>
|
||||
<HeaderNav />
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import React, { useMemo } from "react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { DotsVerticalIcon, TrashIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import SlideScale from "../../components/PresentationRender";
|
||||
|
||||
export const PresentationCard = ({
|
||||
id,
|
||||
title,
|
||||
created_at,
|
||||
slide,
|
||||
onDeleted
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
slide: any;
|
||||
onDeleted?: (presentationId: string) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
|
||||
|
||||
const handlePreview = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
router.push(`/presentation?id=${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
const response = await DashboardApi.deletePresentation(id);
|
||||
|
||||
if (response) {
|
||||
toast.success("Presentation deleted", {
|
||||
description: "The presentation has been deleted successfully",
|
||||
});
|
||||
if (onDeleted) {
|
||||
onDeleted(id);
|
||||
}
|
||||
} else {
|
||||
toast.error("Error deleting presentation");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
onClick={handlePreview}
|
||||
|
||||
className="bg-white rounded-[8px] slide-theme cursor-pointer overflow-hidden p-4"
|
||||
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Date */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#667085] text-sm font-roboto pt-2">
|
||||
{new Date(created_at).toLocaleDateString()}
|
||||
</p>
|
||||
<Popover>
|
||||
<PopoverTrigger className="w-6 h-6 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
|
||||
<DotsVerticalIcon className="w-4 h-4 text-gray-500" />
|
||||
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="bg-white w-[200px]">
|
||||
<button
|
||||
className="flex items-center justify-between w-full px-2 py-1 hover:bg-gray-100"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<p>Delete</p>
|
||||
<TrashIcon className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className=" slide-box relative overflow-hidden border aspect-video"
|
||||
style={{
|
||||
|
||||
}}
|
||||
>
|
||||
<div className="absolute bg-transparent z-40 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
|
||||
<SlideScale slide={slide} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon and Title */}
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-full h-full max-w-[20px] max-h-[20px]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M15.75 0.75V6C15.75 6.42 16.08 6.75 16.5 6.75H21.75M9.75 17.25H7.5C7.08 17.25 6.75 16.92 6.75 16.5V12C6.75 11.58 7.08 11.25 7.5 11.25H13.5C13.92 11.25 14.25 11.58 14.25 12V14.25M21.75 6.3V22.5C21.75 22.92 21.42 23.25 21 23.25H3C2.58 23.25 2.25 22.92 2.25 22.5V1.5C2.25 1.08 2.58 0.75 3 0.75H16.275C16.47 0.75 16.665 0.825 16.815 0.975L21.54 5.775C21.675 5.925 21.75 6.105 21.75 6.3ZM10.5 14.25H16.5C16.92 14.25 17.25 14.58 17.25 15V19.5C17.25 19.92 16.92 20.25 16.5 20.25H10.5C10.08 20.25 9.75 19.92 9.75 19.5V15C9.75 14.58 10.08 14.25 10.5 14.25Z"
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-[#667085] text-sm ml-1 line-clamp-2 font-roboto">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import React from "react";
|
||||
import { PresentationCard } from "./PresentationCard";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PresentationResponse } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
|
||||
interface PresentationGridProps {
|
||||
presentations: PresentationResponse[];
|
||||
type: "slide" | "video";
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onPresentationDeleted?: (presentationId: string) => void;
|
||||
}
|
||||
|
||||
export const PresentationGrid = ({
|
||||
presentations,
|
||||
type,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onPresentationDeleted,
|
||||
}: PresentationGridProps) => {
|
||||
const router = useRouter();
|
||||
const handleCreateNewPresentation = () => {
|
||||
if (type === "slide") {
|
||||
router.push("/upload");
|
||||
} else {
|
||||
router.push("/editor");
|
||||
}
|
||||
};
|
||||
|
||||
const ShimmerCard = () => (
|
||||
<div className="flex flex-col gap-4 min-h-[200px] bg-white/70 rounded-lg p-4 animate-pulse">
|
||||
<div className="w-full h-24 bg-gray-200 rounded-lg"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CreateNewCard = () => (
|
||||
<div
|
||||
onClick={handleCreateNewPresentation}
|
||||
className="flex flex-col gap-4 min-h-[200px] cursor-pointer group border border-gray-400 hover:border-primary/60 bg-white/70 hover:bg-white/80 rounded-lg items-center justify-center transition-all duration-300"
|
||||
>
|
||||
<div className="rounded-full bg-gray-200 group-hover:bg-primary/10 p-4 transition-all duration-300">
|
||||
<PlusIcon className="w-8 h-8 text-gray-500 group-hover:text-primary transition-all duration-300" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-gray-700 group-hover:text-gray-900 mb-1">
|
||||
Create {type === "slide" ? "New" : "Video"} Presentation
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 group-hover:text-gray-600 px-4">
|
||||
Start from scratch and bring your ideas to life
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="flex flex-col gap-4 min-h-[200px] cursor-pointer group border border-gray-400 bg-white/70 rounded-lg items-center justify-center animate-pulse">
|
||||
<div className="rounded-full bg-gray-200 p-4">
|
||||
<div className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-32 mx-auto"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-48 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<ShimmerCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateNewCard />
|
||||
<div className="col-span-3 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-primary hover:text-primary/80 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateNewCard />
|
||||
{presentations &&
|
||||
presentations.length > 0 &&
|
||||
presentations.map((presentation) => (
|
||||
<PresentationCard
|
||||
key={presentation.id}
|
||||
id={presentation.id}
|
||||
title={presentation.title}
|
||||
created_at={presentation.created_at}
|
||||
slide={presentation.slides[0]}
|
||||
onDeleted={onPresentationDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Presentation } from '../types';
|
||||
|
||||
export const PresentationListItem: React.FC<Presentation> = ({
|
||||
title,
|
||||
date,
|
||||
thumbnail,
|
||||
type
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="relative w-[120px] aspect-video rounded-md overflow-hidden">
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={title}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{/* {formatDistanceToNow(new Date(date), { addSuffix: true })} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{type === 'video' ? (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12L9 8V16L15 12Z" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4H20V16H4V4Z" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import React from 'react'
|
||||
import Header from './components/Header'
|
||||
import Wrapper from '@/components/Wrapper'
|
||||
|
||||
const loading = () => {
|
||||
return (
|
||||
<div className=''>
|
||||
<Header />
|
||||
<Wrapper className=''>
|
||||
<div className='container mx-auto px-4 py-8'>
|
||||
|
||||
<h2 className="text-2xl font-roboto font-medium my-6">
|
||||
Slide Presentation
|
||||
</h2>
|
||||
<div className=" mx-auto pb-10 grid xl:grid-cols-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 ">
|
||||
{
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-72 w-full bg-gray-300 aspect-video mx-auto" />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default loading
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
export interface Presentation {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
thumbnail: string;
|
||||
type: 'video' | 'slide';
|
||||
}
|
||||
|
||||
export interface PresentationFilter {
|
||||
type?: 'video' | 'slide';
|
||||
search?: string;
|
||||
dateRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ class ThemeApi {
|
|||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("font_file", font);
|
||||
const response = await fetch(getApiUrl(`${process.env.NEXT_PUBLIC_FAST_API}/api/v1/ppt/fonts/upload`), {
|
||||
const response: any = await fetch(getApiUrl(`/api/v1/ppt/fonts/upload`), {
|
||||
method: "POST",
|
||||
headers: getHeaderForFormData(),
|
||||
body: formData,
|
||||
|
|
@ -105,7 +105,7 @@ class ThemeApi {
|
|||
}
|
||||
static async getUserFonts() {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`${process.env.NEXT_PUBLIC_FAST_API}/api/v1/ppt/fonts/uploaded`), {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/uploaded`), {
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,351 +0,0 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Loader2, Download, CheckCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { notify } from "@/components/ui/sonner";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
getLLMConfigValidationError,
|
||||
handleSaveLLMConfig,
|
||||
} from "@/utils/storeHelpers";
|
||||
import {
|
||||
checkIfSelectedOllamaModelIsPulled,
|
||||
pullOllamaModel,
|
||||
} from "@/utils/providerUtils";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import LLMProviderSelection from "@/components/LLMSelection";
|
||||
import Header from "../dashboard/components/Header";
|
||||
import { LLMConfig } from "@/types/llm_config";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
// Button state interface
|
||||
interface ButtonState {
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
text: string;
|
||||
showProgress: boolean;
|
||||
progressPercentage?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const SettingsPage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const userConfigState = useSelector((state: RootState) => state.userConfig);
|
||||
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
|
||||
userConfigState.llm_config
|
||||
);
|
||||
const canChangeKeys = userConfigState.can_change_keys;
|
||||
const [buttonState, setButtonState] = useState<ButtonState>({
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
showProgress: false,
|
||||
});
|
||||
|
||||
const [downloadingModel, setDownloadingModel] = useState<{
|
||||
name: string;
|
||||
size: number | null;
|
||||
downloaded: number | null;
|
||||
status: string;
|
||||
done: boolean;
|
||||
} | null>(null);
|
||||
const [showDownloadModal, setShowDownloadModal] = useState<boolean>(false);
|
||||
const downloadAbortRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
const downloadProgress = React.useMemo(() => {
|
||||
if (
|
||||
downloadingModel &&
|
||||
downloadingModel.downloaded !== null &&
|
||||
downloadingModel.size !== null
|
||||
) {
|
||||
return Math.round(
|
||||
(downloadingModel.downloaded / downloadingModel.size) * 100
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}, [downloadingModel?.downloaded, downloadingModel?.size]);
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
|
||||
const validationError = getLLMConfigValidationError(llmConfig);
|
||||
if (validationError) {
|
||||
notify.error("Cannot save settings", validationError);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
isDisabled: true,
|
||||
text: "Saving Configuration...",
|
||||
}));
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_API_Call);
|
||||
await handleSaveLLMConfig(llmConfig);
|
||||
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
|
||||
trackEvent(MixpanelEvent.Settings_CheckOllamaModelPulled_API_Call);
|
||||
const isPulled = await checkIfSelectedOllamaModelIsPulled(
|
||||
llmConfig.OLLAMA_MODEL
|
||||
);
|
||||
if (!isPulled) {
|
||||
setShowDownloadModal(true);
|
||||
setDownloadingModel({
|
||||
name: llmConfig.OLLAMA_MODEL || "",
|
||||
size: null,
|
||||
downloaded: null,
|
||||
status: "pulling",
|
||||
done: false,
|
||||
});
|
||||
trackEvent(MixpanelEvent.Settings_DownloadOllamaModel_API_Call);
|
||||
const downloadOutcome = await handleModelDownload();
|
||||
if (downloadOutcome === "cancelled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
notify.info(
|
||||
"Settings saved",
|
||||
"Your configuration was saved successfully."
|
||||
);
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
}));
|
||||
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
|
||||
router.push("/upload");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Something went wrong while saving.";
|
||||
notify.error("Could not save settings", message);
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelDownload = async (): Promise<"completed" | "cancelled"> => {
|
||||
const ac = new AbortController();
|
||||
downloadAbortRef.current = ac;
|
||||
try {
|
||||
await pullOllamaModel(
|
||||
llmConfig.OLLAMA_MODEL!,
|
||||
setDownloadingModel,
|
||||
ac.signal
|
||||
);
|
||||
return "completed";
|
||||
} catch (e) {
|
||||
const aborted = e instanceof Error && e.name === "AbortError";
|
||||
if (aborted) {
|
||||
setDownloadingModel(null);
|
||||
setShowDownloadModal(false);
|
||||
setButtonState({
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
showProgress: false,
|
||||
});
|
||||
notify.info(
|
||||
"Download cancelled",
|
||||
"The Ollama model download was stopped. Your settings are already saved—you can save again to retry the download."
|
||||
);
|
||||
return "cancelled";
|
||||
}
|
||||
setDownloadingModel(null);
|
||||
setShowDownloadModal(false);
|
||||
throw e;
|
||||
} finally {
|
||||
downloadAbortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
downloadingModel &&
|
||||
downloadingModel.downloaded !== null &&
|
||||
downloadingModel.size !== null
|
||||
) {
|
||||
const percentage = Math.round(
|
||||
(downloadingModel.downloaded / downloadingModel.size) * 100
|
||||
);
|
||||
setButtonState({
|
||||
isLoading: true,
|
||||
isDisabled: true,
|
||||
text: `Downloading Model (${percentage}%)`,
|
||||
showProgress: true,
|
||||
progressPercentage: percentage,
|
||||
status: downloadingModel.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadingModel && downloadingModel.done) {
|
||||
setTimeout(() => {
|
||||
setShowDownloadModal(false);
|
||||
setDownloadingModel(null);
|
||||
notify.success(
|
||||
"Model ready",
|
||||
"The Ollama model finished downloading successfully."
|
||||
);
|
||||
}, 2000);
|
||||
}
|
||||
}, [downloadingModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canChangeKeys) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [canChangeKeys, router]);
|
||||
|
||||
if (!canChangeKeys) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
{/* LLM Selection Component */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<LLMProviderSelection
|
||||
initialLLMConfig={llmConfig}
|
||||
onConfigChange={setLlmConfig}
|
||||
buttonState={buttonState}
|
||||
setButtonState={setButtonState}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Fixed Bottom Button */}
|
||||
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={buttonState.isDisabled}
|
||||
className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${
|
||||
buttonState.isDisabled
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
} text-white`}
|
||||
>
|
||||
{buttonState.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{buttonState.text}
|
||||
</div>
|
||||
) : (
|
||||
buttonState.text
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Progress Modal */}
|
||||
{showDownloadModal && downloadingModel && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
|
||||
{/* Modal Content */}
|
||||
<div className="text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-4">
|
||||
{downloadingModel.done ? (
|
||||
<CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{downloadingModel.done
|
||||
? "Download Complete!"
|
||||
: "Downloading Model"}
|
||||
</h3>
|
||||
|
||||
{/* Model Name */}
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
{llmConfig.OLLAMA_MODEL}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{downloadProgress > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{downloadProgress}% Complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{downloadingModel.status && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-700 capitalize">
|
||||
{downloadingModel.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Message */}
|
||||
{downloadingModel.status &&
|
||||
downloadingModel.status !== "pulled" && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{downloadingModel.status === "downloading" &&
|
||||
"Downloading model files..."}
|
||||
{downloadingModel.status === "verifying" &&
|
||||
"Verifying model integrity..."}
|
||||
{downloadingModel.status === "pulling" &&
|
||||
"Pulling model from registry..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Info */}
|
||||
{downloadingModel.downloaded && downloadingModel.size && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex justify-between text-xs text-gray-600">
|
||||
<span>
|
||||
Downloaded:{" "}
|
||||
{(downloadingModel.downloaded / 1024 / 1024).toFixed(1)}{" "}
|
||||
MB
|
||||
</span>
|
||||
<span>
|
||||
Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)}{" "}
|
||||
MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!downloadingModel.done && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-lg border-gray-300 text-gray-800 hover:bg-gray-50"
|
||||
onClick={() => downloadAbortRef.current?.abort()}
|
||||
>
|
||||
Cancel download
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function LoadingProfile() {
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded-full" />
|
||||
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Skeleton */}
|
||||
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* LLM Selection Content Skeleton */}
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Page Title */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-5 w-72 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* LLM Provider Cards */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Card key={index} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="space-y-1">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-4 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-6 bg-gray-200 animate-pulse rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Configuration Fields */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(2)].map((_, fieldIndex) => (
|
||||
<div key={fieldIndex} className="space-y-2">
|
||||
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Fixed Bottom Button Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="h-12 w-full bg-gray-200 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const isDisabled = process.env.DISABLE_ANONYMOUS_TELEMETRY === 'true' || process.env.DISABLE_ANONYMOUS_TELEMETRY === 'True';
|
||||
const isDisabled = process.env.DISABLE_ANONYMOUS_TRACKING === 'true' || process.env.DISABLE_ANONYMOUS_TRACKING === 'True';
|
||||
const telemetryEnabled = !isDisabled;
|
||||
return NextResponse.json({ telemetryEnabled });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,138 +157,10 @@ export default function Home() {
|
|||
}
|
||||
|
||||
return (
|
||||
// <div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
// <main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
// {/* Branding Header */}
|
||||
// <div className="text-center mb-2 mt-4 flex-shrink-0">
|
||||
// <div className="flex items-center justify-center gap-3 mb-2">
|
||||
// <img src="/Logo.png" alt="Presenton Logo" className="h-12" />
|
||||
// </div>
|
||||
// <p className="text-gray-600 text-sm">
|
||||
// Open-source AI presentation generator
|
||||
// </p>
|
||||
// </div>
|
||||
|
||||
// {/* Main Configuration Card */}
|
||||
// <div className="flex-1 overflow-hidden">
|
||||
// <LLMProviderSelection
|
||||
// initialLLMConfig={llmConfig}
|
||||
// onConfigChange={setLlmConfig}
|
||||
// buttonState={buttonState}
|
||||
// setButtonState={setButtonState}
|
||||
// />
|
||||
// </div>
|
||||
// </main>
|
||||
|
||||
// {/* Download Progress Modal */}
|
||||
// {showDownloadModal && downloadingModel && (
|
||||
// <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
// <div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
|
||||
// {/* Modal Content */}
|
||||
// <div className="text-center">
|
||||
// {/* Icon */}
|
||||
// <div className="mb-4">
|
||||
// {downloadingModel.done ? (
|
||||
// <CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
|
||||
// ) : (
|
||||
// <Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// {/* Title */}
|
||||
// <h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
// {downloadingModel.done ? "Download Complete!" : "Downloading Model"}
|
||||
// </h3>
|
||||
|
||||
// {/* Model Name */}
|
||||
// <p className="text-sm text-gray-600 mb-6">
|
||||
// {llmConfig.OLLAMA_MODEL}
|
||||
// </p>
|
||||
|
||||
// {/* Progress Bar */}
|
||||
// {downloadProgress > 0 && (
|
||||
// <div className="mb-4">
|
||||
// <div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
// <div
|
||||
// className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
|
||||
// style={{ width: `${downloadProgress}%` }}
|
||||
// />
|
||||
// </div>
|
||||
// <p className="text-sm text-gray-600 mt-2">
|
||||
// {downloadProgress}% Complete
|
||||
// </p>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Status */}
|
||||
// {downloadingModel.status && (
|
||||
// <div className="flex items-center justify-center gap-2 mb-4">
|
||||
// <CheckCircle className="w-4 h-4 text-green-600" />
|
||||
// <span className="text-sm font-medium text-green-700 capitalize">
|
||||
// {downloadingModel.status}
|
||||
// </span>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Status Message */}
|
||||
// {downloadingModel.status && downloadingModel.status !== "pulled" && (
|
||||
// <div className="text-xs text-gray-500">
|
||||
// {downloadingModel.status === "downloading" && "Downloading model files..."}
|
||||
// {downloadingModel.status === "verifying" && "Verifying model integrity..."}
|
||||
// {downloadingModel.status === "pulling" && "Pulling model from registry..."}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Download Info */}
|
||||
// {downloadingModel.downloaded && downloadingModel.size && (
|
||||
// <div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
// <div className="flex justify-between text-xs text-gray-600">
|
||||
// <span>Downloaded: {(downloadingModel.downloaded / 1024 / 1024).toFixed(1)} MB</span>
|
||||
// <span>Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Fixed Bottom Button */}
|
||||
// <div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
// <div className="container mx-auto max-w-3xl">
|
||||
// <button
|
||||
// onClick={handleSaveConfig}
|
||||
// disabled={buttonState.isDisabled}
|
||||
// className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
|
||||
// ? "bg-gray-400 cursor-not-allowed"
|
||||
// : "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
// } text-white`}
|
||||
// >
|
||||
// {buttonState.isLoading ? (
|
||||
// <div className="flex items-center justify-center gap-2">
|
||||
// <Loader2 className="w-4 h-4 animate-spin" />
|
||||
// {buttonState.text}
|
||||
// </div>
|
||||
// ) : (
|
||||
// buttonState.text
|
||||
// )}
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
<div className="flex h-screen">
|
||||
<div className="flex min-h-screen ">
|
||||
<OnBoardingSlidebar step={step} />
|
||||
<main className="w-full pl-20 pr-8 max-w-[1440px] mx-auto relative z-10">
|
||||
{step === 3 && (
|
||||
<div className="pointer-events-none fixed left-0 top-0 z-0 overflow-hidden" aria-hidden>
|
||||
<img src="/left-confetti.png" alt="presenton" className='w-full h-full object-contain' />
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="pointer-events-none fixed right-0 top-0 z-0 overflow-hidden" aria-hidden>
|
||||
<img src="/right-confetti.png" alt="presenton" className='w-full h-full object-contain' />
|
||||
</div>
|
||||
)}
|
||||
<main className="w-full pl-20 pr-8 pb-5 max-w-[1440px] mx-auto relative z-10">
|
||||
<OnBoardingHeader currentStep={step} setStep={setStep} />
|
||||
{step === 1 && <ModeSelectStep selectedMode={selectedMode} setStep={setStep} setSelectedMode={setSelectedMode} />}
|
||||
{step === 2 && selectedMode === "presenton" && <PresentonMode currentStep={step} setStep={setStep} />}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,77 @@
|
|||
import { ArrowRight } from 'lucide-react'
|
||||
import { ArrowRight, PartyPopper } from 'lucide-react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { trackEvent, MixpanelEvent, setTelemetryEnabled } from "@/utils/mixpanel";
|
||||
import { Switch } from '../ui/switch';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
const CONFETTI_COLORS = ['#ff00c5', '#f3ff00', '#9500d0', '#00d2f2', '#00ea9b', '#ff7f36'];
|
||||
|
||||
function fireRealisticConfetti() {
|
||||
confetti({
|
||||
particleCount: 300,
|
||||
spread: 360,
|
||||
origin: { x: 0.5, y: 0.5 },
|
||||
colors: CONFETTI_COLORS,
|
||||
startVelocity: 60,
|
||||
scalar: 1.8,
|
||||
gravity: 0.6,
|
||||
ticks: 300,
|
||||
decay: 0.93,
|
||||
zIndex: 9999,
|
||||
});
|
||||
}
|
||||
|
||||
const FinalStep = () => {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [trackingEnabled, setTrackingEnabled] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fireRealisticConfetti();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
if (window.electron?.telemetryStatus) {
|
||||
const data = await window.electron.telemetryStatus();
|
||||
setTrackingEnabled(data.telemetryEnabled);
|
||||
} else {
|
||||
const res = await fetch('/api/telemetry-status');
|
||||
const data = await res.json();
|
||||
setTrackingEnabled(data.telemetryEnabled);
|
||||
}
|
||||
} catch {
|
||||
setTrackingEnabled(true);
|
||||
}
|
||||
}
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const handleTrackingToggle = useCallback(async (enabled: boolean) => {
|
||||
const prev = trackingEnabled;
|
||||
setTrackingEnabled(enabled);
|
||||
setTelemetryEnabled(enabled);
|
||||
try {
|
||||
if (window.electron?.setUserConfig) {
|
||||
await window.electron.setUserConfig({
|
||||
DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : 'true',
|
||||
} as any);
|
||||
} else {
|
||||
await fetch('/api/user-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
DISABLE_ANONYMOUS_TRACKING: enabled ? undefined : 'true',
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setTrackingEnabled(prev);
|
||||
setTelemetryEnabled(prev ?? true);
|
||||
}
|
||||
}, [trackingEnabled]);
|
||||
|
||||
const handleGoToDashboard = () => {
|
||||
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" });
|
||||
router.push('/dashboard')
|
||||
|
|
@ -20,8 +86,26 @@ const FinalStep = () => {
|
|||
|
||||
<img src="/final_onboarding.png" alt="presenton" className='w-[118px] h-[98px] object-contain' />
|
||||
<h1 className='text-black text-[30px] font-normal font-unbounded py-2.5'>Welcome on board!</h1>
|
||||
<p className='text-[#000000CC] text-xl font-normal font-syne'>Your AI workspace is ready. Let’s create your first presentation.</p>
|
||||
<button onClick={handleGoToUpload} className='bg-[#7C51F8] px-[23px] mt-14 py-[15px] rounded-[70px] text-white text-lg font-syne font-semibold'>My First Presentation</button>
|
||||
<p className='text-[#000000CC] text-xl font-normal font-syne'>Your AI workspace is ready. Let's create your first presentation.</p>
|
||||
|
||||
{trackingEnabled !== null && (
|
||||
<div className='flex items-center gap-3 mt-8 px-5 py-3.5 rounded-[10px] border border-[#EDEEEF] bg-white'>
|
||||
<div>
|
||||
<p className='text-sm font-medium text-[#191919] font-syne'>Anonymous Tracking</p>
|
||||
<p className='text-[11px] text-[#9CA3AF] font-syne leading-tight mt-0.5'>Help improve Presenton with anonymous usage data.</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={trackingEnabled}
|
||||
onCheckedChange={handleTrackingToggle}
|
||||
className='data-[state=checked]:bg-[#7C51F8]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleGoToUpload} className='bg-[#7C51F8] px-[23px] mt-8 py-[15px] rounded-[70px] text-white text-lg font-syne font-semibold'>My First Presentation</button>
|
||||
<button onClick={fireRealisticConfetti} className='mt-3 flex items-center gap-1.5 text-sm text-[#7A5AF8] font-syne font-medium hover:underline'>
|
||||
<PartyPopper className='w-4 h-4' /> Celebrate again!
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={handleGoToDashboard} className='absolute uppercase bottom-20 text-[#7A5AF8] flex items-center gap-2 right-10 text-xs font-normal font-syne'>Go to your dashboard <ArrowRight className='w-4 h-4 text-[#7A5AF8]' /></button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,13 +49,13 @@ const ModeSelectStep = ({ selectedMode, setStep, setSelectedMode }: { selectedMo
|
|||
<ChevronRight className='w-6 h-6 text-[#B3B3B3]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute bottom-16 mr-8 max-w-[1440px] right-0 flex justify-end items-center gap-2.5 '>
|
||||
<div className='fixed bottom-16 mr-8 max-w-[1440px] right-16 flex justify-end items-center gap-2.5 '>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(2);
|
||||
}}
|
||||
className='border border-[#EDEEEF] bg-[#7C51F8] rounded-[58px] px-5 py-2.5 text-white text-xs font-semibold'>
|
||||
className='border font-syne border-[#EDEEEF] bg-[#7C51F8] rounded-[58px] px-5 py-2.5 text-white text-xs font-semibold'>
|
||||
Start With {selectedMode === "presenton" ? "Presenton" : "Image Model"}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -915,7 +915,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
|
|||
</div>}
|
||||
</div>
|
||||
|
||||
<div className='absolute bottom-16 mr-8 max-w-[1440px] right-0 flex justify-end items-center gap-2.5 '>
|
||||
<div className='fixed bottom-16 mr-8 max-w-[1440px] right-16 flex justify-end items-center gap-2.5 '>
|
||||
<button
|
||||
disabled={currentStep === 1}
|
||||
onClick={() => {
|
||||
|
|
@ -928,7 +928,7 @@ const PresentonMode = ({ currentStep, setStep }: { currentStep: number, setStep:
|
|||
|
||||
disabled={savingConfig}
|
||||
onClick={handleSaveConfig}
|
||||
className='border border-[#EDEEEF] bg-[#7C51F8] rounded-[58px] px-5 py-2.5 text-white text-xs font-semibold'>
|
||||
className='border font-syne border-[#EDEEEF] bg-[#7C51F8] rounded-[58px] px-5 py-2.5 text-white text-xs font-semibold'>
|
||||
Continue to Finish
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
18
electron/servers/nextjs/package-lock.json
generated
18
electron/servers/nextjs/package-lock.json
generated
|
|
@ -37,6 +37,8 @@
|
|||
"@tiptap/extension-underline": "^2.0.0",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
|
@ -3346,6 +3348,12 @@
|
|||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/canvas-confetti": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
|
||||
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/css-font-loading-module": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
|
||||
|
|
@ -4295,6 +4303,16 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-confetti": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
|
||||
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
|
||||
"license": "ISC",
|
||||
"funding": {
|
||||
"type": "donate",
|
||||
"url": "https://www.paypal.me/kirilvatev"
|
||||
}
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@
|
|||
"@tiptap/extension-underline": "^2.0.0",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
3
electron/servers/nextjs/types/global.d.ts
vendored
3
electron/servers/nextjs/types/global.d.ts
vendored
|
|
@ -32,7 +32,7 @@ interface ElectronAPI {
|
|||
// API handlers
|
||||
hasRequiredKey: () => Promise<{ hasKey: boolean }>;
|
||||
telemetryStatus: () => Promise<{ telemetryEnabled: boolean }>;
|
||||
getTemplates: () => Promise<Array<{templateName: string; templateID: string; files: string[]; settings: any }>>;
|
||||
getTemplates: () => Promise<Array<{ templateName: string; templateID: string; files: string[]; settings: any }>>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
|
@ -42,5 +42,6 @@ interface Window {
|
|||
NEXT_PUBLIC_URL: string;
|
||||
TEMP_DIRECTORY: string;
|
||||
NEXT_PUBLIC_USER_CONFIG_PATH: string;
|
||||
APP_VERSION: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import mixpanel from 'mixpanel-browser';
|
||||
|
||||
const MIXPANEL_TOKEN = 'd726e8bea8ec147f4c7720060cb2e6d1';
|
||||
const MIXPANEL_TOKEN = '4ebfc788c739c72a9565c489a7cc2eac';
|
||||
|
||||
export enum MixpanelEvent {
|
||||
PageView = 'Page View',
|
||||
|
|
@ -51,6 +51,31 @@ export enum MixpanelEvent {
|
|||
ImageEditor_GenerateImage_API_Call = 'Image Editor Generate Image API Call',
|
||||
ImageEditor_UploadImage_API_Call = 'Image Editor Upload Image API Call',
|
||||
Header_ReGenerate_Button_Clicked = 'Header ReGenerate Button Clicked',
|
||||
|
||||
Dashboard_Page_Viewed = 'Dashboard Page Viewed',
|
||||
Dashboard_New_Presentation_Clicked = 'Dashboard New Presentation Clicked',
|
||||
Dashboard_Presentation_Opened = 'Dashboard Presentation Opened',
|
||||
Dashboard_Presentation_Deleted = 'Dashboard Presentation Deleted',
|
||||
Dashboard_Create_New_Card_Clicked = 'Dashboard Create New Card Clicked',
|
||||
|
||||
Sidebar_Navigation_Clicked = 'Sidebar Navigation Clicked',
|
||||
|
||||
Templates_Page_Viewed = 'Templates Page Viewed',
|
||||
Templates_Tab_Switched = 'Templates Tab Switched',
|
||||
Templates_Inbuilt_Opened = 'Templates Inbuilt Opened',
|
||||
Templates_Custom_Opened = 'Templates Custom Opened',
|
||||
Templates_New_Template_Clicked = 'Templates New Template Clicked',
|
||||
Templates_Build_Template_Clicked = 'Templates Build Template Clicked',
|
||||
|
||||
Theme_Page_Viewed = 'Theme Page Viewed',
|
||||
Theme_Selected = 'Theme Selected',
|
||||
Theme_Saved = 'Theme Saved',
|
||||
Theme_Deleted = 'Theme Deleted',
|
||||
Theme_Font_Changed = 'Theme Font Changed',
|
||||
Theme_Custom_Font_Uploaded = 'Theme Custom Font Uploaded',
|
||||
Theme_Logo_Uploaded = 'Theme Logo Uploaded',
|
||||
Theme_Tab_Switched = 'Theme Tab Switched',
|
||||
Theme_New_Theme_Clicked = 'Theme New Theme Clicked',
|
||||
}
|
||||
|
||||
export type MixpanelProps = Record<string, unknown>;
|
||||
|
|
@ -107,6 +132,10 @@ export function initMixpanel(): void {
|
|||
if (!enabled) return;
|
||||
if (window.__mixpanel_initialized) return;
|
||||
mixpanel.init(MIXPANEL_TOKEN as string, { track_pageview: false });
|
||||
const appVersion = window.env?.APP_VERSION;
|
||||
if (appVersion) {
|
||||
mixpanel.register({ app_version: appVersion });
|
||||
}
|
||||
mixpanel.identify(mixpanel.get_distinct_id());
|
||||
window.__mixpanel_initialized = true;
|
||||
});
|
||||
|
|
@ -153,12 +182,31 @@ export function identifyAnonymous(): void {
|
|||
mixpanel.identify(mixpanel.get_distinct_id());
|
||||
}
|
||||
|
||||
export function resetTelemetryCache(): void {
|
||||
trackingCheckPromise = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
delete window.__mixpanel_telemetry_enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export function setTelemetryEnabled(enabled: boolean): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mixpanel_telemetry_enabled = enabled;
|
||||
}
|
||||
trackingCheckPromise = null;
|
||||
if (enabled && !window?.__mixpanel_initialized) {
|
||||
initMixpanel();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
initMixpanel,
|
||||
track,
|
||||
trackEvent,
|
||||
getDistinctId,
|
||||
identifyAnonymous,
|
||||
resetTelemetryCache,
|
||||
setTelemetryEnabled,
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {
|
|||
custom: {
|
||||
value: "custom",
|
||||
label: "Custom",
|
||||
description: "Custom LLM",
|
||||
description: "OpenAI-compatible LLM",
|
||||
icon: "/icons/custom.png",
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@
|
|||
"mac": "https://github.com/presenton/presenton/releases/download/electron-v0.7.0-beta/Presenton-0.7.0-beta.dmg",
|
||||
"windows": "https://github.com/presenton/presenton/releases/download/electron-v0.7.0-beta/Presenton-0.7.0-beta.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
|
|||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const isDisabled = process.env.DISABLE_ANONYMOUS_TELEMETRY === 'true' || process.env.DISABLE_ANONYMOUS_TELEMETRY === 'True';
|
||||
const isDisabled = process.env.DISABLE_ANONYMOUS_TRACKING === 'true' || process.env.DISABLE_ANONYMOUS_TRACKING === 'True';
|
||||
const telemetryEnabled = !isDisabled;
|
||||
return NextResponse.json({ telemetryEnabled });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue