feat: add telemetry in electron app & UI improvements

This commit is contained in:
shiva raj badu 2026-04-02 01:10:53 +05:45
parent 692d90e72e
commit 5f191ebf11
No known key found for this signature in database
40 changed files with 373 additions and 1081 deletions

View file

@ -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).

View file

@ -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);

View file

@ -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 || '',
});

View file

@ -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;

View file

@ -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",

View file

@ -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={{

View file

@ -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">

View file

@ -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) {

View file

@ -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 {

View file

@ -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;

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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'>

View file

@ -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'

View file

@ -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'
}}

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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

View file

@ -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;
};
}

View file

@ -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(),
})

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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 });
}

View file

@ -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} />}

View file

@ -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. Lets 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&apos;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>

View file

@ -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>

View file

@ -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>

View file

@ -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",

View file

@ -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

View file

@ -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;
};
}

View file

@ -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,
};

View file

@ -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",
},

View file

@ -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"
}
}
}

View file

@ -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 });
}