chore: format

This commit is contained in:
2025-10-10 16:43:21 +02:00
parent f0aabd63b6
commit 75c29e0ba4
551 changed files with 433948 additions and 94145 deletions

View File

@@ -1,18 +1,19 @@
@import "tailwindcss";
@theme {
/* Custom animations */
--animate-pulse: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
/* Custom keyframes for pulse */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Custom animations */
--animate-pulse: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
/* Custom keyframes for pulse */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
}
@layer base {
@@ -30,18 +31,18 @@
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 10px;
width: 10px;
}
::-webkit-scrollbar-track {
background: rgb(17 24 39);
background: rgb(17 24 39);
}
::-webkit-scrollbar-thumb {
background: rgb(139 92 246);
border-radius: 5px;
background: rgb(139 92 246);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(167 139 250);
background: rgb(167 139 250);
}

View File

@@ -1,57 +1,65 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ['latin'] })
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: 'Pivoine Docs - Documentation Hub',
description: 'Comprehensive documentation hub for all Pivoine projects by Valknar. Explore technical guides, API references, and tutorials.',
keywords: ['documentation', 'pivoine', 'valknar', 'developer', 'guides', 'api'],
authors: [{ name: 'Valknar', url: 'https://pivoine.art' }],
creator: 'Valknar',
manifest: '/manifest.json',
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' },
],
apple: [
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
],
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Pivoine Docs',
},
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://docs.pivoine.art',
title: 'Pivoine Docs - Documentation Hub',
description: 'Comprehensive documentation hub for all Pivoine projects',
siteName: 'Pivoine Docs',
},
twitter: {
card: 'summary_large_image',
title: 'Pivoine Docs - Documentation Hub',
description: 'Comprehensive documentation hub for all Pivoine projects',
},
robots: {
index: true,
follow: true,
},
}
title: "Pivoine Docs - Documentation Hub",
description:
"Comprehensive documentation hub for all Pivoine projects by Valknar. Explore technical guides, API references, and tutorials.",
keywords: [
"documentation",
"pivoine",
"valknar",
"developer",
"guides",
"api",
],
authors: [{ name: "Valknar", url: "https://pivoine.art" }],
creator: "Valknar",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/icon.svg", type: "image/svg+xml", sizes: "any" },
],
apple: [
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "Pivoine Docs",
},
openGraph: {
type: "website",
locale: "en_US",
url: "https://docs.pivoine.art",
title: "Pivoine Docs - Documentation Hub",
description: "Comprehensive documentation hub for all Pivoine projects",
siteName: "Pivoine Docs",
},
twitter: {
card: "summary_large_image",
title: "Pivoine Docs - Documentation Hub",
description: "Comprehensive documentation hub for all Pivoine projects",
},
robots: {
index: true,
follow: true,
},
};
export default function RootLayout({
children,
children,
}: {
children: React.ReactNode
children: React.ReactNode;
}) {
return (
<html lang="en" className="scroll-smooth">
<body className={inter.className}>{children}</body>
</html>
)
return (
<html lang="en" className="scroll-smooth">
<body className={inter.className}>{children}</body>
</html>
);
}

View File

@@ -1,204 +1,241 @@
'use client'
"use client";
import React, { useState, useEffect } from 'react'
import { BookOpen, Code2, Globe, ChevronRight, Sparkles, Terminal } from 'lucide-react'
import KomposeIcon from '@/components/icons/KomposeIcon'
import { PivoineDocsIcon } from '@/components/icons'
import React, { useState, useEffect } from "react";
import {
BookOpen,
Code2,
Globe,
ChevronRight,
Sparkles,
Terminal,
} from "lucide-react";
import KomposeIcon from "@/components/icons/KomposeIcon";
import { PivoineDocsIcon } from "@/components/icons";
export default function DocsHub() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
const [isHovering, setIsHovering] = useState<string | null>(null)
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [isHovering, setIsHovering] = useState<string | null>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({
x: (e.clientX / window.innerWidth) * 20 - 10,
y: (e.clientY / window.innerHeight) * 20 - 10,
})
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [])
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({
x: (e.clientX / window.innerWidth) * 20 - 10,
y: (e.clientY / window.innerHeight) * 20 - 10,
});
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
const projects = [
{
name: 'Kompose',
status: 'Active',
description: 'Comprehensive documentation for Kompose project',
url: '/kompose',
gradient: 'from-violet-500 to-purple-600'
}
]
const projects = [
{
name: "Kompose",
status: "Active",
description: "Comprehensive documentation for Kompose project",
url: "/kompose",
gradient: "from-violet-500 to-purple-600",
},
];
const links = [
{
title: "Valknar's Blog",
icon: Globe,
url: 'http://pivoine.art',
gradient: 'from-pink-500 to-rose-600'
},
{
title: 'Source Code',
icon: Code2,
url: 'https://code.pivoine.art',
gradient: 'from-cyan-500 to-blue-600'
}
]
const links = [
{
title: "Valknar's Blog",
icon: Globe,
url: "http://pivoine.art",
gradient: "from-pink-500 to-rose-600",
},
{
title: "Source Code",
icon: Code2,
url: "https://code.pivoine.art",
gradient: "from-cyan-500 to-blue-600",
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900 text-white overflow-hidden">
{/* Animated background orbs */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div
className="absolute w-96 h-96 bg-purple-500/20 rounded-full blur-3xl top-0 -left-48 animate-pulse"
style={{
transform: `translate(${mousePosition.x}px, ${mousePosition.y}px)`,
transition: 'transform 0.3s ease-out'
}}
/>
<div
className="absolute w-96 h-96 bg-pink-500/20 rounded-full blur-3xl bottom-0 -right-48 animate-pulse"
style={{
transform: `translate(${-mousePosition.x}px, ${-mousePosition.y}px)`,
transition: 'transform 0.3s ease-out',
animationDelay: '1s'
}}
/>
<div className="absolute w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900 text-white overflow-hidden">
{/* Animated background orbs */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div
className="absolute w-96 h-96 bg-purple-500/20 rounded-full blur-3xl top-0 -left-48 animate-pulse"
style={{
transform: `translate(${mousePosition.x}px, ${mousePosition.y}px)`,
transition: "transform 0.3s ease-out",
}}
/>
<div
className="absolute w-96 h-96 bg-pink-500/20 rounded-full blur-3xl bottom-0 -right-48 animate-pulse"
style={{
transform: `translate(${-mousePosition.x}px, ${-mousePosition.y}px)`,
transition: "transform 0.3s ease-out",
animationDelay: "1s",
}}
/>
<div
className="absolute w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-pulse"
style={{ animationDelay: "0.5s" }}
/>
</div>
{/* Main content */}
<div className="relative z-10 container mx-auto px-6 py-12 max-w-6xl">
{/* Header */}
<header className="text-center mb-20 pt-12">
{/* Hero Icon */}
<div className="flex justify-center mb-8">
<PivoineDocsIcon size="200px" showLabel={false} interactive={true} />
</div>
<div className="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-white/5 backdrop-blur-sm rounded-full border border-white/10">
<Sparkles className="w-4 h-4 text-purple-400" />
<span className="text-sm text-purple-300">Documentation Hub</span>
</div>
<h1 className="text-7xl font-bold mb-6 bg-gradient-to-r from-white via-purple-200 to-pink-200 bg-clip-text text-transparent animate-pulse">
Pivoine Docs
</h1>
<p className="text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed">
Comprehensive documentation for all projects by <span className="text-purple-400 font-semibold">Valknar</span>.
Explore technical guides, API references, and tutorials.
</p>
</header>
{/* Main content */}
<div className="relative z-10 container mx-auto px-6 py-12 max-w-6xl">
{/* Header */}
<header className="text-center mb-20 pt-12">
{/* Hero Icon */}
<div className="flex justify-center mb-8">
<PivoineDocsIcon
size="200px"
showLabel={false}
interactive={true}
/>
</div>
{/* Projects Grid */}
<section className="mb-20">
<div className="flex items-center gap-3 mb-8">
<Terminal className="w-6 h-6 text-purple-400" />
<h2 className="text-3xl font-bold">Project Documentation</h2>
</div>
<div className="grid md:grid-cols-2 gap-6">
{projects.map((project, idx) => (
<a
key={idx}
href={project.url}
onMouseEnter={() => setIsHovering(project.name)}
onMouseLeave={() => setIsHovering(null)}
className="group relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-500/20"
>
<div className="absolute inset-0 bg-gradient-to-br opacity-0 group-hover:opacity-10 rounded-2xl transition-opacity duration-300"
style={{ background: `linear-gradient(135deg, rgb(168, 85, 247), rgb(147, 51, 234))` }} />
<div className="relative">
<div className="flex items-start justify-between mb-4">
{project.name === 'Kompose' ? (
<div className={`relative w-14 h-14 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg flex items-center justify-center`}>
<KomposeIcon size="36px" interactive={false} className='' />
</div>
) : (
<div className={`p-3 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg`}>
<BookOpen className="w-8 h-8 text-white" />
</div>
)}
<span className="px-3 py-1 bg-emerald-500/20 text-emerald-300 rounded-full text-sm border border-emerald-500/30">
{project.status}
</span>
</div>
<h3 className="text-2xl font-bold mb-3 group-hover:text-purple-300 transition-colors">
{project.name}
</h3>
<p className="text-gray-400 mb-4 leading-relaxed">
{project.description}
</p>
<div className="flex items-center text-purple-400 font-semibold group-hover:gap-3 gap-2 transition-all">
Read docs
<ChevronRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</a>
))}
{/* Coming Soon Card */}
<div className="relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-dashed border-white/20">
<div className="opacity-60">
<div className="p-3 rounded-xl bg-gradient-to-br from-gray-600 to-gray-700 w-fit mb-4">
<BookOpen className="w-8 h-8 text-white" />
</div>
<h3 className="text-2xl font-bold mb-3 text-gray-400">More Projects</h3>
<p className="text-gray-500 leading-relaxed">
Additional documentation sites coming soon...
</p>
</div>
</div>
</div>
</section>
<div className="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-white/5 backdrop-blur-sm rounded-full border border-white/10">
<Sparkles className="w-4 h-4 text-purple-400" />
<span className="text-sm text-purple-300">Documentation Hub</span>
</div>
{/* External Links */}
<section>
<div className="flex items-center gap-3 mb-8">
<Sparkles className="w-6 h-6 text-pink-400" />
<h2 className="text-3xl font-bold">Explore More</h2>
</div>
<div className="grid md:grid-cols-2 gap-6">
{links.map((link, idx) => {
const Icon = link.icon
return (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="group bg-white/5 backdrop-blur-md rounded-2xl p-6 border border-white/10 hover:border-pink-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-pink-500/20 flex items-center gap-4"
>
<div className={`p-4 rounded-xl bg-gradient-to-br ${link.gradient} shadow-lg group-hover:scale-110 transition-transform`}>
<Icon className="w-7 h-7 text-white" />
</div>
<div className="flex-1">
<h3 className="text-xl font-bold group-hover:text-pink-300 transition-colors">
{link.title}
</h3>
<p className="text-gray-400 text-sm">{link.url}</p>
</div>
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-pink-400 group-hover:translate-x-1 transition-all" />
</a>
)
})}
</div>
</section>
<h1 className="text-7xl font-bold mb-6 bg-gradient-to-r from-white via-purple-200 to-pink-200 bg-clip-text text-transparent animate-pulse">
Pivoine Docs
</h1>
{/* Footer */}
<footer className="mt-20 pt-8 border-t border-white/10 text-center text-gray-400">
<p className="text-sm">
Crafted with passion by <span className="text-purple-400 font-semibold">Valknar</span> ·
<a href="http://pivoine.art" className="hover:text-purple-300 transition-colors ml-1">pivoine.art</a>
</p>
</footer>
</div>
</div>
)
<p className="text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed">
Comprehensive documentation for all projects by{" "}
<span className="text-purple-400 font-semibold">Valknar</span>.
Explore technical guides, API references, and tutorials.
</p>
</header>
{/* Projects Grid */}
<section className="mb-20">
<div className="flex items-center gap-3 mb-8">
<Terminal className="w-6 h-6 text-purple-400" />
<h2 className="text-3xl font-bold">Project Documentation</h2>
</div>
<div className="grid md:grid-cols-2 gap-6">
{projects.map((project, idx) => (
<a
key={idx}
href={project.url}
onMouseEnter={() => setIsHovering(project.name)}
onMouseLeave={() => setIsHovering(null)}
className="group relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-500/20"
>
<div
className="absolute inset-0 bg-gradient-to-br opacity-0 group-hover:opacity-10 rounded-2xl transition-opacity duration-300"
style={{
background: `linear-gradient(135deg, rgb(168, 85, 247), rgb(147, 51, 234))`,
}}
/>
<div className="relative">
<div className="flex items-start justify-between mb-4">
{project.name === "Kompose" ? (
<div
className={`relative w-14 h-14 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg flex items-center justify-center`}
>
<KomposeIcon
size="36px"
interactive={false}
className=""
/>
</div>
) : (
<div
className={`p-3 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg`}
>
<BookOpen className="w-8 h-8 text-white" />
</div>
)}
<span className="px-3 py-1 bg-emerald-500/20 text-emerald-300 rounded-full text-sm border border-emerald-500/30">
{project.status}
</span>
</div>
<h3 className="text-2xl font-bold mb-3 group-hover:text-purple-300 transition-colors">
{project.name}
</h3>
<p className="text-gray-400 mb-4 leading-relaxed">
{project.description}
</p>
<div className="flex items-center text-purple-400 font-semibold group-hover:gap-3 gap-2 transition-all">
Read docs
<ChevronRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</a>
))}
{/* Coming Soon Card */}
<div className="relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-dashed border-white/20">
<div className="opacity-60">
<div className="p-3 rounded-xl bg-gradient-to-br from-gray-600 to-gray-700 w-fit mb-4">
<BookOpen className="w-8 h-8 text-white" />
</div>
<h3 className="text-2xl font-bold mb-3 text-gray-400">
More Projects
</h3>
<p className="text-gray-500 leading-relaxed">
Additional documentation sites coming soon...
</p>
</div>
</div>
</div>
</section>
{/* External Links */}
<section>
<div className="flex items-center gap-3 mb-8">
<Sparkles className="w-6 h-6 text-pink-400" />
<h2 className="text-3xl font-bold">Explore More</h2>
</div>
<div className="grid md:grid-cols-2 gap-6">
{links.map((link, idx) => {
const Icon = link.icon;
return (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="group bg-white/5 backdrop-blur-md rounded-2xl p-6 border border-white/10 hover:border-pink-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-pink-500/20 flex items-center gap-4"
>
<div
className={`p-4 rounded-xl bg-gradient-to-br ${link.gradient} shadow-lg group-hover:scale-110 transition-transform`}
>
<Icon className="w-7 h-7 text-white" />
</div>
<div className="flex-1">
<h3 className="text-xl font-bold group-hover:text-pink-300 transition-colors">
{link.title}
</h3>
<p className="text-gray-400 text-sm">{link.url}</p>
</div>
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-pink-400 group-hover:translate-x-1 transition-all" />
</a>
);
})}
</div>
</section>
{/* Footer */}
<footer className="mt-20 pt-8 border-t border-white/10 text-center text-gray-400">
<p className="text-sm">
Crafted with passion by{" "}
<span className="text-purple-400 font-semibold">Valknar</span> ·
<a
href="http://pivoine.art"
className="hover:text-purple-300 transition-colors ml-1"
>
pivoine.art
</a>
</p>
</footer>
</div>
</div>
);
}

View File

@@ -1,302 +1,407 @@
'use client'
"use client";
import PivoineDocsIcon from './PivoineDocsIcon'
import PivoineDocsIcon from "./PivoineDocsIcon";
export default function PivoineIconDemo() {
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
padding: '4rem 2rem',
color: '#fff'
}}>
<div style={{
maxWidth: '1400px',
margin: '0 auto'
}}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '4rem' }}>
<h1 style={{
fontSize: '3rem',
fontWeight: 'bold',
background: 'linear-gradient(135deg, #ec4899, #a855f7, #c084fc)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
marginBottom: '1rem'
}}>
Pivoine Docs Icon
</h1>
<p style={{
fontSize: '1.25rem',
color: '#94a3b8',
maxWidth: '600px',
margin: '0 auto'
}}>
A beautiful animated peony blossom icon with interactive states
</p>
</div>
return (
<div
style={{
minHeight: "100vh",
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
padding: "4rem 2rem",
color: "#fff",
}}
>
<div
style={{
maxWidth: "1400px",
margin: "0 auto",
}}
>
{/* Header */}
<div style={{ textAlign: "center", marginBottom: "4rem" }}>
<h1
style={{
fontSize: "3rem",
fontWeight: "bold",
background: "linear-gradient(135deg, #ec4899, #a855f7, #c084fc)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
marginBottom: "1rem",
}}
>
Pivoine Docs Icon
</h1>
<p
style={{
fontSize: "1.25rem",
color: "#94a3b8",
maxWidth: "600px",
margin: "0 auto",
}}
>
A beautiful animated peony blossom icon with interactive states
</p>
</div>
{/* Main Showcase */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '3rem',
marginBottom: '4rem'
}}>
{/* Large Interactive */}
<div style={{
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '1rem',
padding: '2rem',
textAlign: 'center',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}>
<h3 style={{ marginBottom: '1.5rem', color: '#f472b6' }}>
Interactive (Hover & Click)
</h3>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '320px'
}}>
<PivoineDocsIcon size="280px" />
</div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
Hover to bloom Click to close
</p>
</div>
{/* Main Showcase */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "3rem",
marginBottom: "4rem",
}}
>
{/* Large Interactive */}
<div
style={{
background: "rgba(255, 255, 255, 0.05)",
borderRadius: "1rem",
padding: "2rem",
textAlign: "center",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<h3 style={{ marginBottom: "1.5rem", color: "#f472b6" }}>
Interactive (Hover & Click)
</h3>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "320px",
}}
>
<PivoineDocsIcon size="280px" />
</div>
<p
style={{
color: "#94a3b8",
fontSize: "0.875rem",
marginTop: "1rem",
}}
>
Hover to bloom Click to close
</p>
</div>
{/* With Label */}
<div style={{
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '1rem',
padding: '2rem',
textAlign: 'center',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}>
<h3 style={{ marginBottom: '1.5rem', color: '#c084fc' }}>
With Label
</h3>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '320px'
}}>
<PivoineDocsIcon size="240px" showLabel />
</div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
Perfect for hero sections
</p>
</div>
{/* With Label */}
<div
style={{
background: "rgba(255, 255, 255, 0.05)",
borderRadius: "1rem",
padding: "2rem",
textAlign: "center",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<h3 style={{ marginBottom: "1.5rem", color: "#c084fc" }}>
With Label
</h3>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "320px",
}}
>
<PivoineDocsIcon size="240px" showLabel />
</div>
<p
style={{
color: "#94a3b8",
fontSize: "0.875rem",
marginTop: "1rem",
}}
>
Perfect for hero sections
</p>
</div>
{/* Non-Interactive */}
<div style={{
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '1rem',
padding: '2rem',
textAlign: 'center',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}>
<h3 style={{ marginBottom: '1.5rem', color: '#fb7185' }}>
Static (Non-Interactive)
</h3>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '320px'
}}>
<PivoineDocsIcon size="240px" interactive={false} />
</div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
Ideal for favicons & PWA icons
</p>
</div>
</div>
{/* Non-Interactive */}
<div
style={{
background: "rgba(255, 255, 255, 0.05)",
borderRadius: "1rem",
padding: "2rem",
textAlign: "center",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<h3 style={{ marginBottom: "1.5rem", color: "#fb7185" }}>
Static (Non-Interactive)
</h3>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "320px",
}}
>
<PivoineDocsIcon size="240px" interactive={false} />
</div>
<p
style={{
color: "#94a3b8",
fontSize: "0.875rem",
marginTop: "1rem",
}}
>
Ideal for favicons & PWA icons
</p>
</div>
</div>
{/* Size Variations */}
<div style={{
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '1rem',
padding: '3rem',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
marginBottom: '4rem'
}}>
<h2 style={{
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '2rem',
textAlign: 'center',
color: '#f0abfc'
}}>
Size Variations
</h2>
<div style={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'flex-end',
flexWrap: 'wrap',
gap: '2rem',
padding: '2rem'
}}>
<div style={{ textAlign: 'center' }}>
<PivoineDocsIcon size="64px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
64px<br />Favicon
</p>
</div>
<div style={{ textAlign: 'center' }}>
<PivoineDocsIcon size="96px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
96px<br />Small
</p>
</div>
<div style={{ textAlign: 'center' }}>
<PivoineDocsIcon size="128px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
128px<br />Medium
</p>
</div>
<div style={{ textAlign: 'center' }}>
<PivoineDocsIcon size="192px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
192px<br />Large
</p>
</div>
<div style={{ textAlign: 'center' }}>
<PivoineDocsIcon size="256px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
256px<br />X-Large
</p>
</div>
</div>
</div>
{/* Size Variations */}
<div
style={{
background: "rgba(255, 255, 255, 0.05)",
borderRadius: "1rem",
padding: "3rem",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.1)",
marginBottom: "4rem",
}}
>
<h2
style={{
fontSize: "2rem",
fontWeight: "bold",
marginBottom: "2rem",
textAlign: "center",
color: "#f0abfc",
}}
>
Size Variations
</h2>
<div
style={{
display: "flex",
justifyContent: "space-around",
alignItems: "flex-end",
flexWrap: "wrap",
gap: "2rem",
padding: "2rem",
}}
>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="64px" />
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
64px
<br />
Favicon
</p>
</div>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="96px" />
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
96px
<br />
Small
</p>
</div>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="128px" />
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
128px
<br />
Medium
</p>
</div>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="192px" />
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
192px
<br />
Large
</p>
</div>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="256px" />
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
256px
<br />
X-Large
</p>
</div>
</div>
</div>
{/* Feature List */}
<div style={{
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '1rem',
padding: '3rem',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}>
<h2 style={{
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '2rem',
textAlign: 'center',
color: '#f0abfc'
}}>
Features
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '2rem'
}}>
{[
{
icon: '🌸',
title: 'Realistic Design',
description: 'Multi-layered peony with natural gradients'
},
{
icon: '✨',
title: 'Smooth Animations',
description: 'Gentle breathing in normal state'
},
{
icon: '🎭',
title: 'Interactive States',
description: 'Bloom on hover, close on click'
},
{
icon: '💫',
title: 'Particle Effects',
description: '12 bloom particles flying around'
},
{
icon: '🎨',
title: 'Beautiful Colors',
description: 'Pink to purple gradient palette'
},
{
icon: '♿',
title: 'Accessible',
description: 'Reduced motion & touch support'
},
{
icon: '📱',
title: 'Responsive',
description: 'Works perfectly on all devices'
},
{
icon: '⚡',
title: 'High Performance',
description: 'GPU-accelerated CSS animations'
}
].map((feature, i) => (
<div key={i} style={{
padding: '1.5rem',
background: 'rgba(255, 255, 255, 0.03)',
borderRadius: '0.75rem',
border: '1px solid rgba(255, 255, 255, 0.08)'
}}>
<div style={{ fontSize: '2rem', marginBottom: '0.75rem' }}>
{feature.icon}
</div>
<h4 style={{
fontSize: '1.125rem',
fontWeight: '600',
marginBottom: '0.5rem',
color: '#fda4af'
}}>
{feature.title}
</h4>
<p style={{
fontSize: '0.875rem',
color: '#94a3b8'
}}>
{feature.description}
</p>
</div>
))}
</div>
</div>
{/* Feature List */}
<div
style={{
background: "rgba(255, 255, 255, 0.05)",
borderRadius: "1rem",
padding: "3rem",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<h2
style={{
fontSize: "2rem",
fontWeight: "bold",
marginBottom: "2rem",
textAlign: "center",
color: "#f0abfc",
}}
>
Features
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
gap: "2rem",
}}
>
{[
{
icon: "🌸",
title: "Realistic Design",
description: "Multi-layered peony with natural gradients",
},
{
icon: "✨",
title: "Smooth Animations",
description: "Gentle breathing in normal state",
},
{
icon: "🎭",
title: "Interactive States",
description: "Bloom on hover, close on click",
},
{
icon: "💫",
title: "Particle Effects",
description: "12 bloom particles flying around",
},
{
icon: "🎨",
title: "Beautiful Colors",
description: "Pink to purple gradient palette",
},
{
icon: "♿",
title: "Accessible",
description: "Reduced motion & touch support",
},
{
icon: "📱",
title: "Responsive",
description: "Works perfectly on all devices",
},
{
icon: "⚡",
title: "High Performance",
description: "GPU-accelerated CSS animations",
},
].map((feature, i) => (
<div
key={i}
style={{
padding: "1.5rem",
background: "rgba(255, 255, 255, 0.03)",
borderRadius: "0.75rem",
border: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<div style={{ fontSize: "2rem", marginBottom: "0.75rem" }}>
{feature.icon}
</div>
<h4
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
color: "#fda4af",
}}
>
{feature.title}
</h4>
<p
style={{
fontSize: "0.875rem",
color: "#94a3b8",
}}
>
{feature.description}
</p>
</div>
))}
</div>
</div>
{/* Usage Example */}
<div style={{
marginTop: '4rem',
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '1rem',
padding: '2rem',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}>
<h2 style={{
fontSize: '1.5rem',
fontWeight: 'bold',
marginBottom: '1rem',
color: '#f0abfc'
}}>
Quick Start
</h2>
<pre style={{
background: 'rgba(0, 0, 0, 0.3)',
padding: '1.5rem',
borderRadius: '0.5rem',
overflow: 'auto',
fontSize: '0.875rem',
color: '#e2e8f0'
}}>
{`import PivoineDocsIcon from '@/components/icons/PivoineDocsIcon'
{/* Usage Example */}
<div
style={{
marginTop: "4rem",
background: "rgba(255, 255, 255, 0.05)",
borderRadius: "1rem",
padding: "2rem",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255, 255, 255, 0.1)",
}}
>
<h2
style={{
fontSize: "1.5rem",
fontWeight: "bold",
marginBottom: "1rem",
color: "#f0abfc",
}}
>
Quick Start
</h2>
<pre
style={{
background: "rgba(0, 0, 0, 0.3)",
padding: "1.5rem",
borderRadius: "0.5rem",
overflow: "auto",
fontSize: "0.875rem",
color: "#e2e8f0",
}}
>
{`import PivoineDocsIcon from '@/components/icons/PivoineDocsIcon'
// Basic usage
<PivoineDocsIcon size="256px" />
@@ -306,19 +411,21 @@ export default function PivoineIconDemo() {
// Static for favicon
<PivoineDocsIcon size="128px" interactive={false} />`}
</pre>
</div>
</pre>
</div>
{/* Footer */}
<div style={{
marginTop: '4rem',
textAlign: 'center',
color: '#64748b',
fontSize: '0.875rem'
}}>
<p>Made with 🌸 for beautiful documentation experiences</p>
</div>
</div>
</div>
)
{/* Footer */}
<div
style={{
marginTop: "4rem",
textAlign: "center",
color: "#64748b",
fontSize: "0.875rem",
}}
>
<p>Made with 🌸 for beautiful documentation experiences</p>
</div>
</div>
</div>
);
}

View File

@@ -1,333 +1,334 @@
/* Kompose Icon Styles */
.kompose-icon-wrapper {
position: relative;
display: inline-block;
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-style: preserve-3d;
position: relative;
display: inline-block;
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-style: preserve-3d;
}
.kompose-icon-wrapper:not(.is-interactive) {
cursor: default;
cursor: default;
}
.kompose-icon {
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 4px 20px rgba(0, 220, 130, 0.2));
transition: filter 0.4s ease;
width: 100%;
height: 100%;
display: block;
filter: drop-shadow(0 4px 20px rgba(0, 220, 130, 0.2));
transition: filter 0.4s ease;
}
/* Hover Effects */
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.05) translateY(-2px);
transform: scale(1.05) translateY(-2px);
}
.kompose-icon-wrapper.is-interactive:hover .kompose-icon {
filter: drop-shadow(0 8px 30px rgba(0, 220, 130, 0.4));
animation: subtle-pulse 2s ease-in-out infinite;
filter: drop-shadow(0 8px 30px rgba(0, 220, 130, 0.4));
animation: subtle-pulse 2s ease-in-out infinite;
}
.kompose-icon-wrapper.is-interactive:hover .bg-rect {
animation: bg-glow 2s ease-in-out infinite;
animation: bg-glow 2s ease-in-out infinite;
}
.kompose-icon-wrapper.is-interactive:hover .k-letter {
animation: letter-glow 1.5s ease-in-out infinite;
animation: letter-glow 1.5s ease-in-out infinite;
}
.kompose-icon-wrapper.is-interactive:hover .k-vertical {
animation: line-slide-vertical 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
animation: line-slide-vertical 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.kompose-icon-wrapper.is-interactive:hover .k-diagonal-top {
animation: line-slide-diagonal-top 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s;
animation: line-slide-diagonal-top 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s;
}
.kompose-icon-wrapper.is-interactive:hover .k-diagonal-bottom {
animation: line-slide-diagonal-bottom 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s;
animation: line-slide-diagonal-bottom 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)
0.2s;
}
.kompose-icon-wrapper.is-interactive:hover .status-dot {
animation: pulse-expand 1s ease-in-out infinite;
animation: pulse-expand 1s ease-in-out infinite;
}
.kompose-icon-wrapper.is-interactive:hover .status-ring {
animation: ring-pulse 1.5s ease-in-out infinite;
animation: ring-pulse 1.5s ease-in-out infinite;
}
.kompose-icon-wrapper.is-interactive:hover .corner {
opacity: 1 !important;
animation: corner-extend 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
opacity: 1 !important;
animation: corner-extend 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Click/Active Effects */
.kompose-icon-wrapper.is-clicked {
animation: click-bounce 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
animation: click-bounce 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.kompose-icon-wrapper.is-clicked .kompose-icon {
animation: rotate-3d 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
filter: drop-shadow(0 12px 40px rgba(0, 220, 130, 0.6));
animation: rotate-3d 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
filter: drop-shadow(0 12px 40px rgba(0, 220, 130, 0.6));
}
.kompose-icon-wrapper.is-clicked .k-letter {
animation: letter-flash 0.6s ease-out;
filter: url(#intenseglow192);
animation: letter-flash 0.6s ease-out;
filter: url(#intenseglow192);
}
.kompose-icon-wrapper.is-clicked .status-dot {
animation: dot-burst 0.6s ease-out;
animation: dot-burst 0.6s ease-out;
}
/* Ripple Effect */
.ripple {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(0, 220, 130, 0.6) 0%,
rgba(0, 220, 130, 0) 70%
);
transform: translate(-50%, -50%) scale(0);
animation: ripple-expand 0.8s ease-out;
pointer-events: none;
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(0, 220, 130, 0.6) 0%,
rgba(0, 220, 130, 0) 70%
);
transform: translate(-50%, -50%) scale(0);
animation: ripple-expand 0.8s ease-out;
pointer-events: none;
}
/* Default animations for status dot */
.status-dot {
animation: default-pulse 2s ease-in-out infinite;
animation: default-pulse 2s ease-in-out infinite;
}
.status-ring {
animation: default-ring-pulse 2s ease-in-out infinite;
animation: default-ring-pulse 2s ease-in-out infinite;
}
/* Keyframe Animations */
@keyframes subtle-pulse {
0%,
100% {
filter: drop-shadow(0 8px 30px rgba(0, 220, 130, 0.4));
}
50% {
filter: drop-shadow(0 8px 35px rgba(0, 220, 130, 0.6));
}
0%,
100% {
filter: drop-shadow(0 8px 30px rgba(0, 220, 130, 0.4));
}
50% {
filter: drop-shadow(0 8px 35px rgba(0, 220, 130, 0.6));
}
}
@keyframes bg-glow {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(1.1);
}
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(1.1);
}
}
@keyframes letter-glow {
0%,
100% {
filter: url(#glow192);
}
50% {
filter: url(#intenseglow192);
}
0%,
100% {
filter: url(#glow192);
}
50% {
filter: url(#intenseglow192);
}
}
@keyframes line-slide-vertical {
0% {
stroke-dasharray: 96;
stroke-dashoffset: 96;
}
100% {
stroke-dasharray: 96;
stroke-dashoffset: 0;
}
0% {
stroke-dasharray: 96;
stroke-dashoffset: 96;
}
100% {
stroke-dasharray: 96;
stroke-dashoffset: 0;
}
}
@keyframes line-slide-diagonal-top {
0% {
stroke-dasharray: 68;
stroke-dashoffset: 68;
}
100% {
stroke-dasharray: 68;
stroke-dashoffset: 0;
}
0% {
stroke-dasharray: 68;
stroke-dashoffset: 68;
}
100% {
stroke-dasharray: 68;
stroke-dashoffset: 0;
}
}
@keyframes line-slide-diagonal-bottom {
0% {
stroke-dasharray: 68;
stroke-dashoffset: 68;
}
100% {
stroke-dasharray: 68;
stroke-dashoffset: 0;
}
0% {
stroke-dasharray: 68;
stroke-dashoffset: 68;
}
100% {
stroke-dasharray: 68;
stroke-dashoffset: 0;
}
}
@keyframes pulse-expand {
0%,
100% {
r: 11.52;
opacity: 0.9;
}
50% {
r: 14;
opacity: 1;
}
0%,
100% {
r: 11.52;
opacity: 0.9;
}
50% {
r: 14;
opacity: 1;
}
}
@keyframes ring-pulse {
0%,
100% {
r: 17.28;
opacity: 0.3;
stroke-width: 3;
}
50% {
r: 20;
opacity: 0.6;
stroke-width: 2;
}
0%,
100% {
r: 17.28;
opacity: 0.3;
stroke-width: 3;
}
50% {
r: 20;
opacity: 0.6;
stroke-width: 2;
}
}
@keyframes corner-extend {
0% {
stroke-dasharray: 13.44;
stroke-dashoffset: 13.44;
}
100% {
stroke-dasharray: 13.44;
stroke-dashoffset: 0;
}
0% {
stroke-dasharray: 13.44;
stroke-dashoffset: 13.44;
}
100% {
stroke-dasharray: 13.44;
stroke-dashoffset: 0;
}
}
@keyframes click-bounce {
0% {
transform: scale(1) translateY(0) rotateY(0deg);
}
30% {
transform: scale(0.92) translateY(0) rotateY(0deg);
}
50% {
transform: scale(1.08) translateY(-4px) rotateY(180deg);
}
70% {
transform: scale(0.98) translateY(0) rotateY(360deg);
}
100% {
transform: scale(1) translateY(0) rotateY(360deg);
}
0% {
transform: scale(1) translateY(0) rotateY(0deg);
}
30% {
transform: scale(0.92) translateY(0) rotateY(0deg);
}
50% {
transform: scale(1.08) translateY(-4px) rotateY(180deg);
}
70% {
transform: scale(0.98) translateY(0) rotateY(360deg);
}
100% {
transform: scale(1) translateY(0) rotateY(360deg);
}
}
@keyframes rotate-3d {
0% {
transform: perspective(800px) rotateY(0deg);
}
50% {
transform: perspective(800px) rotateY(180deg);
}
100% {
transform: perspective(800px) rotateY(360deg);
}
0% {
transform: perspective(800px) rotateY(0deg);
}
50% {
transform: perspective(800px) rotateY(180deg);
}
100% {
transform: perspective(800px) rotateY(360deg);
}
}
@keyframes letter-flash {
0%,
100% {
opacity: 1;
}
20%,
60% {
opacity: 0.7;
}
40%,
80% {
opacity: 1;
}
0%,
100% {
opacity: 1;
}
20%,
60% {
opacity: 0.7;
}
40%,
80% {
opacity: 1;
}
}
@keyframes dot-burst {
0% {
r: 11.52;
opacity: 0.9;
}
50% {
r: 20;
opacity: 1;
}
100% {
r: 11.52;
opacity: 0.9;
}
0% {
r: 11.52;
opacity: 0.9;
}
50% {
r: 20;
opacity: 1;
}
100% {
r: 11.52;
opacity: 0.9;
}
}
@keyframes ripple-expand {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
}
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
}
}
@keyframes default-pulse {
0%,
100% {
opacity: 0.6;
r: 11.52;
}
50% {
opacity: 1;
r: 13.44;
}
0%,
100% {
opacity: 0.6;
r: 11.52;
}
50% {
opacity: 1;
r: 13.44;
}
}
@keyframes default-ring-pulse {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.5;
}
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.5;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.03) translateY(-1px);
}
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.03) translateY(-1px);
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.kompose-icon-wrapper,
.kompose-icon,
.kompose-icon *,
.ripple {
animation: none !important;
transition: none !important;
}
.kompose-icon-wrapper,
.kompose-icon,
.kompose-icon *,
.ripple {
animation: none !important;
transition: none !important;
}
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.02);
}
.kompose-icon-wrapper.is-interactive:hover {
transform: scale(1.02);
}
}
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
.kompose-icon-wrapper.is-interactive:active {
transform: scale(0.95);
}
.kompose-icon-wrapper.is-interactive:active {
transform: scale(0.95);
}
}

View File

@@ -1,119 +1,254 @@
'use client'
"use client";
import React, { useState } from 'react'
import './KomposeIcon.css'
import React, { useState } from "react";
import "./KomposeIcon.css";
interface KomposeIconProps {
size?: string
interactive?: boolean
className?: string
size?: string;
interactive?: boolean;
className?: string;
}
export default function KomposeIcon({
size = '192px',
interactive = true,
className = ''
export default function KomposeIcon({
size = "192px",
interactive = true,
className = "",
}: KomposeIconProps) {
const [isClicked, setIsClicked] = useState(false)
const [showRipple, setShowRipple] = useState(false)
const [isClicked, setIsClicked] = useState(false);
const [showRipple, setShowRipple] = useState(false);
const handleClick = () => {
if (!interactive) return
const handleClick = () => {
if (!interactive) return;
setIsClicked(true)
setShowRipple(true)
setIsClicked(true);
setShowRipple(true);
setTimeout(() => {
setIsClicked(false)
}, 600)
setTimeout(() => {
setIsClicked(false);
}, 600);
setTimeout(() => {
setShowRipple(false)
}, 800)
}
setTimeout(() => {
setShowRipple(false);
}, 800);
};
const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return
handleClick()
}
const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return;
handleClick();
};
const wrapperClasses = [
'kompose-icon-wrapper',
isClicked && 'is-clicked',
interactive && 'is-interactive',
className
].filter(Boolean).join(' ')
const wrapperClasses = [
"kompose-icon-wrapper",
isClicked && "is-clicked",
interactive && "is-interactive",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
className={wrapperClasses}
onClick={handleClick}
onTouchStart={handleTouch}
style={{ width: size, height: size }}
>
<svg
className="kompose-icon"
viewBox="0 0 192 192"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern id="carbon192" x="0" y="0" width="7.68" height="7.68" patternUnits="userSpaceOnUse">
<rect width="7.68" height="7.68" fill="#0a0e27"></rect>
<path d="M0,0 L3.84,3.84 M3.84,0 L7.68,3.84 M0,3.84 L3.84,7.68" stroke="#060815" strokeWidth="1.5" opacity="0.5"></path>
</pattern>
return (
<div
className={wrapperClasses}
onClick={handleClick}
onTouchStart={handleTouch}
style={{ width: size, height: size }}
>
<svg
className="kompose-icon"
viewBox="0 0 192 192"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern
id="carbon192"
x="0"
y="0"
width="7.68"
height="7.68"
patternUnits="userSpaceOnUse"
>
<rect width="7.68" height="7.68" fill="#0a0e27"></rect>
<path
d="M0,0 L3.84,3.84 M3.84,0 L7.68,3.84 M0,3.84 L3.84,7.68"
stroke="#060815"
strokeWidth="1.5"
opacity="0.5"
></path>
</pattern>
<linearGradient id="bgGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#1a1d2e', stopOpacity: 1 }}></stop>
<stop offset="100%" style={{ stopColor: '#0a0e27', stopOpacity: 1 }}></stop>
</linearGradient>
<linearGradient id="bgGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
<stop
offset="0%"
style={{ stopColor: "#1a1d2e", stopOpacity: 1 }}
></stop>
<stop
offset="100%"
style={{ stopColor: "#0a0e27", stopOpacity: 1 }}
></stop>
</linearGradient>
<linearGradient id="primaryGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" className="gradient-start" style={{ stopColor: '#00DC82', stopOpacity: 1 }}></stop>
<stop offset="100%" className="gradient-end" style={{ stopColor: '#00a86b', stopOpacity: 1 }}></stop>
</linearGradient>
<linearGradient
id="primaryGrad192"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop
offset="0%"
className="gradient-start"
style={{ stopColor: "#00DC82", stopOpacity: 1 }}
></stop>
<stop
offset="100%"
className="gradient-end"
style={{ stopColor: "#00a86b", stopOpacity: 1 }}
></stop>
</linearGradient>
<filter id="glow192">
<feGaussianBlur stdDeviation="6" result="coloredBlur"></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<filter id="glow192">
<feGaussianBlur
stdDeviation="6"
result="coloredBlur"
></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
<filter id="intenseglow192">
<feGaussianBlur stdDeviation="12" result="coloredBlur"></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
</defs>
<filter id="intenseglow192">
<feGaussianBlur
stdDeviation="12"
result="coloredBlur"
></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
</feMerge>
</filter>
</defs>
{/* Background */}
<rect className="bg-rect" width="192" height="192" rx="24" fill="url(#bgGrad192)"></rect>
<rect className="carbon-pattern" width="192" height="192" rx="24" fill="url(#carbon192)" opacity="0.4"></rect>
{/* Background */}
<rect
className="bg-rect"
width="192"
height="192"
rx="24"
fill="url(#bgGrad192)"
></rect>
<rect
className="carbon-pattern"
width="192"
height="192"
rx="24"
fill="url(#carbon192)"
opacity="0.4"
></rect>
{/* Stylized K */}
<g className="k-letter" transform="translate(48, 48)">
<line className="k-line k-vertical" x1="0" y1="0" x2="0" y2="96" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line>
<line className="k-line k-diagonal-top" x1="0" y1="48" x2="57.6" y2="0" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line>
<line className="k-line k-diagonal-bottom" x1="0" y1="48" x2="57.6" y2="96" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line>
</g>
{/* Stylized K */}
<g className="k-letter" transform="translate(48, 48)">
<line
className="k-line k-vertical"
x1="0"
y1="0"
x2="0"
y2="96"
stroke="url(#primaryGrad192)"
strokeWidth="15"
strokeLinecap="round"
filter="url(#glow192)"
></line>
<line
className="k-line k-diagonal-top"
x1="0"
y1="48"
x2="57.6"
y2="0"
stroke="url(#primaryGrad192)"
strokeWidth="15"
strokeLinecap="round"
filter="url(#glow192)"
></line>
<line
className="k-line k-diagonal-bottom"
x1="0"
y1="48"
x2="57.6"
y2="96"
stroke="url(#primaryGrad192)"
strokeWidth="15"
strokeLinecap="round"
filter="url(#glow192)"
></line>
</g>
{/* Animated status dot */}
<circle className="status-dot" cx="163.2" cy="163.2" r="11.52" fill="#00DC82" opacity="0.9"></circle>
<circle className="status-ring" cx="163.2" cy="163.2" r="17.28" fill="none" stroke="#00DC82" strokeWidth="3" opacity="0.3"></circle>
{/* Animated status dot */}
<circle
className="status-dot"
cx="163.2"
cy="163.2"
r="11.52"
fill="#00DC82"
opacity="0.9"
></circle>
<circle
className="status-ring"
cx="163.2"
cy="163.2"
r="17.28"
fill="none"
stroke="#00DC82"
strokeWidth="3"
opacity="0.3"
></circle>
{/* Tech corners */}
<line className="corner corner-tl-h" x1="15.36" y1="15.36" x2="28.8" y2="15.36" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
<line className="corner corner-tl-v" x1="15.36" y1="15.36" x2="15.36" y2="28.8" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
<line className="corner corner-tr-h" x1="176.64" y1="15.36" x2="163.2" y2="15.36" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
<line className="corner corner-tr-v" x1="176.64" y1="15.36" x2="176.64" y2="28.8" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
</svg>
{/* Tech corners */}
<line
className="corner corner-tl-h"
x1="15.36"
y1="15.36"
x2="28.8"
y2="15.36"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
<line
className="corner corner-tl-v"
x1="15.36"
y1="15.36"
x2="15.36"
y2="28.8"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
<line
className="corner corner-tr-h"
x1="176.64"
y1="15.36"
x2="163.2"
y2="15.36"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
<line
className="corner corner-tr-v"
x1="176.64"
y1="15.36"
x2="176.64"
y2="28.8"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
</svg>
{/* Ripple effect container */}
{showRipple && <div className="ripple"></div>}
</div>
)
{/* Ripple effect container */}
{showRipple && <div className="ripple"></div>}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,338 +1,494 @@
'use client'
"use client";
import React, { useState } from 'react'
import './PivoineDocsIcon.css'
import React, { useState } from "react";
import "./PivoineDocsIcon.css";
interface PivoineDocsIconProps {
size?: string
interactive?: boolean
className?: string
showLabel?: boolean
size?: string;
interactive?: boolean;
className?: string;
showLabel?: boolean;
}
export default function PivoineDocsIcon({
size = '256px',
interactive = true,
className = '',
showLabel = false
export default function PivoineDocsIcon({
size = "256px",
interactive = true,
className = "",
showLabel = false,
}: PivoineDocsIconProps) {
const [isHovered, setIsHovered] = useState(false)
const [isClicked, setIsClicked] = useState(false)
const [isHovered, setIsHovered] = useState(false);
const [isClicked, setIsClicked] = useState(false);
const handleMouseEnter = () => {
if (!interactive) return
setIsHovered(true)
}
const handleMouseEnter = () => {
if (!interactive) return;
setIsHovered(true);
};
const handleMouseLeave = () => {
if (!interactive) return
setIsHovered(false)
}
const handleMouseLeave = () => {
if (!interactive) return;
setIsHovered(false);
};
const handleClick = () => {
if (!interactive) return
const handleClick = () => {
if (!interactive) return;
setIsClicked(true)
setTimeout(() => {
setIsClicked(false)
}, 1200)
}
setIsClicked(true);
setTimeout(() => {
setIsClicked(false);
}, 1200);
};
const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return
e.preventDefault()
setIsHovered(true)
setTimeout(() => {
handleClick()
}, 50)
const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return;
e.preventDefault();
setIsHovered(true);
setTimeout(() => {
setIsHovered(false)
}, 1500)
}
setTimeout(() => {
handleClick();
}, 50);
const wrapperClasses = [
'pivoine-docs-icon-wrapper',
isHovered && 'is-hovered',
isClicked && 'is-clicked',
interactive && 'is-interactive',
className
].filter(Boolean).join(' ')
setTimeout(() => {
setIsHovered(false);
}, 1500);
};
// Generate bloom particles with varied properties
const bloomParticles = Array.from({ length: 12 }, (_, i) => ({
id: i,
angle: (360 / 12) * i,
distance: 80 + Math.random() * 20,
size: 2 + Math.random() * 2,
delay: i * 0.08,
}))
const wrapperClasses = [
"pivoine-docs-icon-wrapper",
isHovered && "is-hovered",
isClicked && "is-clicked",
interactive && "is-interactive",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
className={wrapperClasses}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onTouchStart={handleTouch}
style={{ width: size, height: size, rotate: '5deg' }}
>
<svg
className="pivoine-docs-icon"
viewBox="0 0 256 256"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
{/* Enhanced Gradients for natural peony colors */}
<radialGradient id="petal-gradient-1" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fdf4ff', stopOpacity: 1 }} />
<stop offset="40%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
<stop offset="70%" style={{ stopColor: '#f0abfc', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#d946ef', stopOpacity: 0.95 }} />
</radialGradient>
// Generate bloom particles with varied properties
const bloomParticles = Array.from({ length: 12 }, (_, i) => ({
id: i,
angle: (360 / 12) * i,
distance: 80 + Math.random() * 20,
size: 2 + Math.random() * 2,
delay: i * 0.08,
}));
<radialGradient id="petal-gradient-2" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
<stop offset="40%" style={{ stopColor: '#f3e8ff', stopOpacity: 1 }} />
<stop offset="70%" style={{ stopColor: '#e9d5ff', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#c084fc', stopOpacity: 0.95 }} />
</radialGradient>
return (
<div
className={wrapperClasses}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onTouchStart={handleTouch}
style={{ width: size, height: size, rotate: "5deg" }}
>
<svg
className="pivoine-docs-icon"
viewBox="0 0 256 256"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
{/* Enhanced Gradients for natural peony colors */}
<radialGradient id="petal-gradient-1" cx="30%" cy="30%">
<stop
offset="0%"
style={{ stopColor: "#fdf4ff", stopOpacity: 1 }}
/>
<stop
offset="40%"
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#f0abfc", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#d946ef", stopOpacity: 0.95 }}
/>
</radialGradient>
<radialGradient id="petal-gradient-3" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fdf4ff', stopOpacity: 1 }} />
<stop offset="40%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
<stop offset="70%" style={{ stopColor: '#f0abfc', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#d946ef', stopOpacity: 0.95 }} />
</radialGradient>
<radialGradient id="petal-gradient-2" cx="30%" cy="30%">
<stop
offset="0%"
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
/>
<stop
offset="40%"
style={{ stopColor: "#f3e8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#e9d5ff", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#c084fc", stopOpacity: 0.95 }}
/>
</radialGradient>
<radialGradient id="petal-gradient-4" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
<stop offset="40%" style={{ stopColor: '#f3e8ff', stopOpacity: 1 }} />
<stop offset="70%" style={{ stopColor: '#e9d5ff', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#c084fc', stopOpacity: 0.95 }} />
</radialGradient>
<radialGradient id="petal-gradient-3" cx="30%" cy="30%">
<stop
offset="0%"
style={{ stopColor: "#fdf4ff", stopOpacity: 1 }}
/>
<stop
offset="40%"
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#f0abfc", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#d946ef", stopOpacity: 0.95 }}
/>
</radialGradient>
<radialGradient id="center-gradient" cx="50%" cy="50%">
<stop offset="0%" style={{ stopColor: '#fef3c7', stopOpacity: 1 }} />
<stop offset="30%" style={{ stopColor: '#fde68a', stopOpacity: 1 }} />
<stop offset="60%" style={{ stopColor: '#fbbf24', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#f59e0b', stopOpacity: 1 }} />
</radialGradient>
<radialGradient id="petal-gradient-4" cx="30%" cy="30%">
<stop
offset="0%"
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
/>
<stop
offset="40%"
style={{ stopColor: "#f3e8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#e9d5ff", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#c084fc", stopOpacity: 0.95 }}
/>
</radialGradient>
<radialGradient id="center-inner-gradient" cx="50%" cy="50%">
<stop offset="0%" style={{ stopColor: '#fffbeb', stopOpacity: 1 }} />
<stop offset="50%" style={{ stopColor: '#fef3c7', stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: '#fde68a', stopOpacity: 1 }} />
</radialGradient>
<radialGradient id="center-gradient" cx="50%" cy="50%">
<stop
offset="0%"
style={{ stopColor: "#fef3c7", stopOpacity: 1 }}
/>
<stop
offset="30%"
style={{ stopColor: "#fde68a", stopOpacity: 1 }}
/>
<stop
offset="60%"
style={{ stopColor: "#fbbf24", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#f59e0b", stopOpacity: 1 }}
/>
</radialGradient>
<linearGradient id="page-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#ffffff', stopOpacity: 0.98 }} />
<stop offset="100%" style={{ stopColor: '#f3f4f6', stopOpacity: 0.98 }} />
</linearGradient>
<radialGradient id="center-inner-gradient" cx="50%" cy="50%">
<stop
offset="0%"
style={{ stopColor: "#fffbeb", stopOpacity: 1 }}
/>
<stop
offset="50%"
style={{ stopColor: "#fef3c7", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#fde68a", stopOpacity: 1 }}
/>
</radialGradient>
{/* Enhanced Filters */}
<filter id="petal-glow">
<feGaussianBlur stdDeviation="2.5" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<linearGradient
id="page-gradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop
offset="0%"
style={{ stopColor: "#ffffff", stopOpacity: 0.98 }}
/>
<stop
offset="100%"
style={{ stopColor: "#f3f4f6", stopOpacity: 0.98 }}
/>
</linearGradient>
<filter id="intense-glow">
<feGaussianBlur stdDeviation="8" result="coloredBlur" />
<feComponentTransfer in="coloredBlur" result="brightBlur">
<feFuncA type="linear" slope="1.5" />
</feComponentTransfer>
<feMerge>
<feMergeNode in="brightBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Enhanced Filters */}
<filter id="petal-glow">
<feGaussianBlur stdDeviation="2.5" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="center-glow">
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="intense-glow">
<feGaussianBlur stdDeviation="8" result="coloredBlur" />
<feComponentTransfer in="coloredBlur" result="brightBlur">
<feFuncA type="linear" slope="1.5" />
</feComponentTransfer>
<feMerge>
<feMergeNode in="brightBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="sparkle-glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="center-glow">
<feGaussianBlur stdDeviation="4" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="page-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.15" />
</filter>
</defs>
<filter id="sparkle-glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Subtle background glow */}
<circle className="bg-glow" cx="128" cy="128" r="120" fill="url(#petal-gradient-3)" opacity="0.08" />
<filter id="page-shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" floodOpacity="0.15" />
</filter>
</defs>
{/* Outer layer - Large petals (8 petals) */}
<g className="outer-petals">
{[
{ angle: 0, scaleX: 1.1, scaleY: 1, gradient: 1 },
{ angle: 45, scaleX: 1, scaleY: 1.05, gradient: 2 },
{ angle: 90, scaleX: 1.05, scaleY: 1, gradient: 3 },
{ angle: 135, scaleX: 1, scaleY: 1.1, gradient: 4 },
{ angle: 180, scaleX: 1.08, scaleY: 1, gradient: 1 },
{ angle: 225, scaleX: 1, scaleY: 1.02, gradient: 2 },
{ angle: 270, scaleX: 1.02, scaleY: 1, gradient: 3 },
{ angle: 315, scaleX: 1, scaleY: 1.06, gradient: 4 },
].map((petal, i) => (
<ellipse
key={`outer-${i}`}
className={`petal outer-petal petal-${i}`}
cx="128"
cy="70"
rx="40"
ry="68"
fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)"
style={{rotate: `${petal.angle}deg`, width: `${128 * petal.scaleX}px`, height: `${70 * petal.scaleY}px`}}
/>
))}
</g>
{/* Subtle background glow */}
<circle
className="bg-glow"
cx="128"
cy="128"
r="120"
fill="url(#petal-gradient-3)"
opacity="0.08"
/>
{/* Middle layer - Medium petals (8 petals, offset) */}
<g className="middle-petals">
{[
{ angle: 22.5, scaleX: 1, scaleY: 1, gradient: 2 },
{ angle: 67.5, scaleX: 1.05, scaleY: 1, gradient: 3 },
{ angle: 112.5, scaleX: 1, scaleY: 1.02, gradient: 4 },
{ angle: 157.5, scaleX: 1.02, scaleY: 1, gradient: 1 },
{ angle: 202.5, scaleX: 1, scaleY: 1.05, gradient: 2 },
{ angle: 247.5, scaleX: 1.03, scaleY: 1, gradient: 3 },
{ angle: 292.5, scaleX: 1, scaleY: 1, gradient: 4 },
{ angle: 337.5, scaleX: 1.02, scaleY: 1, gradient: 1 },
].map((petal, i) => (
<ellipse
key={`middle-${i}`}
className={`petal middle-petal petal-m-${i}`}
cx="128"
cy="78"
rx="34"
ry="56"
fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)"
style={{rotate: `${petal.angle}deg`, width: `${128 * petal.scaleX}px`, height: `${70 * petal.scaleY}px`}}
/>
))}
</g>
{/* Outer layer - Large petals (8 petals) */}
<g className="outer-petals">
{[
{ angle: 0, scaleX: 1.1, scaleY: 1, gradient: 1 },
{ angle: 45, scaleX: 1, scaleY: 1.05, gradient: 2 },
{ angle: 90, scaleX: 1.05, scaleY: 1, gradient: 3 },
{ angle: 135, scaleX: 1, scaleY: 1.1, gradient: 4 },
{ angle: 180, scaleX: 1.08, scaleY: 1, gradient: 1 },
{ angle: 225, scaleX: 1, scaleY: 1.02, gradient: 2 },
{ angle: 270, scaleX: 1.02, scaleY: 1, gradient: 3 },
{ angle: 315, scaleX: 1, scaleY: 1.06, gradient: 4 },
].map((petal, i) => (
<ellipse
key={`outer-${i}`}
className={`petal outer-petal petal-${i}`}
cx="128"
cy="70"
rx="40"
ry="68"
fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)"
style={{
rotate: `${petal.angle}deg`,
width: `${128 * petal.scaleX}px`,
height: `${70 * petal.scaleY}px`,
}}
/>
))}
</g>
{/* Inner layer - Small petals (10 petals) */}
<g className="inner-petals">
{[
{ angle: 0, gradient: 3 },
{ angle: 45, gradient: 4 },
{ angle: 90, gradient: 1 },
{ angle: 135, gradient: 2 },
{ angle: 180, gradient: 3 },
{ angle: 225, gradient: 4 },
{ angle: 270, gradient: 1 },
{ angle: 315, gradient: 2 },
].map((petal, i) => (
<ellipse
key={`inner-${i}`}
className={`petal inner-petal petal-i-${i}`}
cx="128"
cy="88"
rx="28"
ry="44"
fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)"
style={{rotate: `${petal.angle}deg`}}
/>
))}
</g>
{/* Middle layer - Medium petals (8 petals, offset) */}
<g className="middle-petals">
{[
{ angle: 22.5, scaleX: 1, scaleY: 1, gradient: 2 },
{ angle: 67.5, scaleX: 1.05, scaleY: 1, gradient: 3 },
{ angle: 112.5, scaleX: 1, scaleY: 1.02, gradient: 4 },
{ angle: 157.5, scaleX: 1.02, scaleY: 1, gradient: 1 },
{ angle: 202.5, scaleX: 1, scaleY: 1.05, gradient: 2 },
{ angle: 247.5, scaleX: 1.03, scaleY: 1, gradient: 3 },
{ angle: 292.5, scaleX: 1, scaleY: 1, gradient: 4 },
{ angle: 337.5, scaleX: 1.02, scaleY: 1, gradient: 1 },
].map((petal, i) => (
<ellipse
key={`middle-${i}`}
className={`petal middle-petal petal-m-${i}`}
cx="128"
cy="78"
rx="34"
ry="56"
fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)"
style={{
rotate: `${petal.angle}deg`,
width: `${128 * petal.scaleX}px`,
height: `${70 * petal.scaleY}px`,
}}
/>
))}
</g>
{/* Center circles - Flower stamen */}
<circle
className="center-circle-outer"
cx="128"
cy="128"
r="12"
fill="url(#center-gradient)"
filter="url(#center-glow)"
/>
<circle
className="center-circle-inner"
cx="128"
cy="128"
r="2"
fill="url(#center-inner-gradient)"
opacity="0.9"
/>
{/* Inner layer - Small petals (10 petals) */}
<g className="inner-petals">
{[
{ angle: 0, gradient: 3 },
{ angle: 45, gradient: 4 },
{ angle: 90, gradient: 1 },
{ angle: 135, gradient: 2 },
{ angle: 180, gradient: 3 },
{ angle: 225, gradient: 4 },
{ angle: 270, gradient: 1 },
{ angle: 315, gradient: 2 },
].map((petal, i) => (
<ellipse
key={`inner-${i}`}
className={`petal inner-petal petal-i-${i}`}
cx="128"
cy="88"
rx="28"
ry="44"
fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)"
style={{ rotate: `${petal.angle}deg` }}
/>
))}
</g>
{/* Center details - tiny stamens */}
<g className="center-stamens">
{Array.from({ length: 8 }).map((_, i) => {
const angle = (360 / 8) * i
const x = 128 + Math.cos((angle * Math.PI) / 180) * 10
const y = 128 + Math.sin((angle * Math.PI) / 180) * 10
return (
<circle
key={`stamen-${i}`}
className={`stamen stamen-${i}`}
cx={x}
cy={y}
r="2"
fill="#d97706"
opacity="0.8"
/>
)
})}
</g>
{/* Center circles - Flower stamen */}
<circle
className="center-circle-outer"
cx="128"
cy="128"
r="12"
fill="url(#center-gradient)"
filter="url(#center-glow)"
/>
<circle
className="center-circle-inner"
cx="128"
cy="128"
r="2"
fill="url(#center-inner-gradient)"
opacity="0.9"
/>
{/* Sparkles - ambient magical effect */}
<g className="sparkles">
<circle className="sparkle sparkle-1" cx="180" cy="75" r="3" fill="#fbbf24" filter="url(#sparkle-glow)" />
<circle className="sparkle sparkle-2" cx="76" cy="76" r="2.5" fill="#a855f7" filter="url(#sparkle-glow)" />
<circle className="sparkle sparkle-3" cx="180" cy="180" r="2.5" fill="#ec4899" filter="url(#sparkle-glow)" />
<circle className="sparkle sparkle-4" cx="76" cy="180" r="3" fill="#c026d3" filter="url(#sparkle-glow)" />
<circle className="sparkle sparkle-5" cx="128" cy="50" r="2" fill="#f0abfc" filter="url(#sparkle-glow)" />
<circle className="sparkle sparkle-6" cx="206" cy="128" r="2" fill="#fb7185" filter="url(#sparkle-glow)" />
<circle className="sparkle sparkle-7" cx="128" cy="206" r="2.5" fill="#fbbf24" filter="url(#sparkle-glow)" />
<circle className="sparkle sparkle-8" cx="50" cy="128" r="2" fill="#c084fc" filter="url(#sparkle-glow)" />
</g>
{/* Center details - tiny stamens */}
<g className="center-stamens">
{Array.from({ length: 8 }).map((_, i) => {
const angle = (360 / 8) * i;
const x = 128 + Math.cos((angle * Math.PI) / 180) * 10;
const y = 128 + Math.sin((angle * Math.PI) / 180) * 10;
return (
<circle
key={`stamen-${i}`}
className={`stamen stamen-${i}`}
cx={x}
cy={y}
r="2"
fill="#d97706"
opacity="0.8"
/>
);
})}
</g>
{/* Flying bloom particles (visible on hover) */}
<g className="bloom-particles">
{bloomParticles.map((particle) => (
<circle
key={`bloom-particle-${particle.id}`}
className={`bloom-particle bloom-particle-${particle.id}`}
cx="128"
cy="128"
r={particle.size}
fill={`url(#petal-gradient-${(particle.id % 4) + 1})`}
opacity="0"
filter="url(#sparkle-glow)"
style={{
'--particle-angle': `${particle.angle}deg`,
'--particle-distance': `${particle.distance}px`,
'--particle-delay': `${particle.delay}s`,
} as React.CSSProperties}
/>
))}
</g>
</svg>
{/* Sparkles - ambient magical effect */}
<g className="sparkles">
<circle
className="sparkle sparkle-1"
cx="180"
cy="75"
r="3"
fill="#fbbf24"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-2"
cx="76"
cy="76"
r="2.5"
fill="#a855f7"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-3"
cx="180"
cy="180"
r="2.5"
fill="#ec4899"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-4"
cx="76"
cy="180"
r="3"
fill="#c026d3"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-5"
cx="128"
cy="50"
r="2"
fill="#f0abfc"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-6"
cx="206"
cy="128"
r="2"
fill="#fb7185"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-7"
cx="128"
cy="206"
r="2.5"
fill="#fbbf24"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-8"
cx="50"
cy="128"
r="2"
fill="#c084fc"
filter="url(#sparkle-glow)"
/>
</g>
{/* Optional label */}
{showLabel && (
<div className="icon-label">
<span className="label-text">Pivoine Docs</span>
</div>
)}
</div>
)
{/* Flying bloom particles (visible on hover) */}
<g className="bloom-particles">
{bloomParticles.map((particle) => (
<circle
key={`bloom-particle-${particle.id}`}
className={`bloom-particle bloom-particle-${particle.id}`}
cx="128"
cy="128"
r={particle.size}
fill={`url(#petal-gradient-${(particle.id % 4) + 1})`}
opacity="0"
filter="url(#sparkle-glow)"
style={
{
"--particle-angle": `${particle.angle}deg`,
"--particle-distance": `${particle.distance}px`,
"--particle-delay": `${particle.delay}s`,
} as React.CSSProperties
}
/>
))}
</g>
</svg>
{/* Optional label */}
{showLabel && (
<div className="icon-label">
<span className="label-text">Pivoine Docs</span>
</div>
)}
</div>
);
}

View File

@@ -1,2 +1,2 @@
export { default as KomposeIcon } from './KomposeIcon'
export { default as PivoineDocsIcon } from './PivoineDocsIcon'
export { default as KomposeIcon } from "./KomposeIcon";
export { default as PivoineDocsIcon } from "./PivoineDocsIcon";

View File

@@ -6,11 +6,11 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@@ -1,57 +1,57 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
reactStrictMode: true,
// Next.js 15 uses turbopack by default for dev
// No need to explicitly enable swcMinify anymore
// Optimize production build
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
output: "export",
reactStrictMode: true,
// Image optimization
images: {
formats: ['image/avif', 'image/webp'],
},
// Next.js 15 uses turbopack by default for dev
// No need to explicitly enable swcMinify anymore
// Headers for security
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-DNS-Prefetch-Control',
value: 'on'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin'
}
]
}
]
},
// Optimize production build
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
// Enable experimental features if needed
experimental: {
// turbo is now stable in Next.js 15
// Add other experimental features here if needed
},
// Image optimization
images: {
formats: ["image/avif", "image/webp"],
},
turbopack: {
root: '.'
}
}
// Headers for security
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin",
},
],
},
];
},
export default nextConfig
// Enable experimental features if needed
experimental: {
// turbo is now stable in Next.js 15
// Add other experimental features here if needed
},
turbopack: {
root: ".",
},
};
export default nextConfig;

View File

@@ -1,36 +1,36 @@
{
"name": "pivoine-docs-hub",
"version": "1.0.0",
"description": "Documentation hub for Pivoine projects by Valknar",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.14",
"lucide-react": "^0.263.1",
"next": "^15.0.3",
"postcss": "^8.5.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.3",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0"
},
"engines": {
"node": ">=18.18.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@9.0.0"
"name": "pivoine-docs-hub",
"version": "1.0.0",
"description": "Documentation hub for Pivoine projects by Valknar",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.14",
"lucide-react": "^0.263.1",
"next": "^15.0.3",
"postcss": "^8.5.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.3",
"tailwindcss": "^4.0.0",
"typescript": "^5.6.0"
},
"engines": {
"node": ">=18.18.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@9.0.0"
}

View File

@@ -1,5 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -1,45 +1,45 @@
{
"name": "Pivoine Docs Hub",
"short_name": "Pivoine Docs",
"description": "Documentation hub for all Pivoine projects by Valknar",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#a855f7",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["documentation", "developer", "tools"],
"screenshots": [
{
"src": "/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshot-narrow.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
]
"name": "Pivoine Docs Hub",
"short_name": "Pivoine Docs",
"description": "Documentation hub for all Pivoine projects by Valknar",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#a855f7",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["documentation", "developer", "tools"],
"screenshots": [
{
"src": "/screenshot-wide.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshot-narrow.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
]
}

View File

@@ -1,8 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
}
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
};

View File

@@ -1,27 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,30 +1,30 @@
{
"buildCommand": "pnpm build",
"devCommand": "pnpm dev",
"installCommand": "pnpm install",
"framework": "nextjs",
"regions": ["iad1"],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "SAMEORIGIN"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Referrer-Policy",
"value": "origin-when-cross-origin"
}
]
}
]
"buildCommand": "pnpm build",
"devCommand": "pnpm dev",
"installCommand": "pnpm install",
"framework": "nextjs",
"regions": ["iad1"],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "SAMEORIGIN"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Referrer-Policy",
"value": "origin-when-cross-origin"
}
]
}
]
}

View File

@@ -4,60 +4,55 @@
// gem install scss-lint
module.exports = function (grunt) {
'use strict';
// Project configuration
grunt.initConfig({
// Metadata
pkg: grunt.file.readJSON('package.json'),
banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' +
'<%= grunt.template.today("yyyy-mm-dd") %>\n' +
'<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' +
'* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' +
' Licensed <%= props.license %> */\n',
"use strict";
// Project configuration
grunt.initConfig({
// Metadata
pkg: grunt.file.readJSON("package.json"),
banner:
"/*! <%= pkg.name %> - v<%= pkg.version %> - " +
'<%= grunt.template.today("yyyy-mm-dd") %>\n' +
'<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' +
'* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' +
" Licensed <%= props.license %> */\n",
webfont: {
icons: {
src: [
'icons/sbed/*.svg',
'icons/lorc/*.svg'
],
dest: 'fonts',
options: {
styles: 'font,icon,extra',
fontFilename: 'game-icons',
types: ['eot', 'woff2', 'woff', 'ttf', 'svg'],
syntax: 'bootstrap',
destCss: 'css',
destScss: 'scss',
templateOptions: {
baseClass: 'gi',
classPrefix: 'gi-'
},
fontFamilyName: 'GameIcons',
font: 'game-icons',
stylesheets: ['css', 'scss'],
fontPathVariables: true,
htmlDemo: false,
}
}
},
// CSS Min
// =======
cssmin: {
target: {
files: {
'css/game-icons.min.css': 'css/game-icons.css'
}
}
}
});
webfont: {
icons: {
src: ["icons/sbed/*.svg", "icons/lorc/*.svg"],
dest: "fonts",
options: {
styles: "font,icon,extra",
fontFilename: "game-icons",
types: ["eot", "woff2", "woff", "ttf", "svg"],
syntax: "bootstrap",
destCss: "css",
destScss: "scss",
templateOptions: {
baseClass: "gi",
classPrefix: "gi-",
},
fontFamilyName: "GameIcons",
font: "game-icons",
stylesheets: ["css", "scss"],
fontPathVariables: true,
htmlDemo: false,
},
},
},
// CSS Min
// =======
cssmin: {
target: {
files: {
"css/game-icons.min.css": "css/game-icons.css",
},
},
},
});
// These plugins provide necessary tasks
grunt.loadNpmTasks('grunt-webfont');
grunt.loadNpmTasks('grunt-contrib-cssmin');
// These plugins provide necessary tasks
grunt.loadNpmTasks("grunt-webfont");
grunt.loadNpmTasks("grunt-contrib-cssmin");
grunt.registerTask('default', [
'webfont',
'cssmin'
]);
grunt.registerTask("default", ["webfont", "cssmin"]);
};

View File

@@ -1,22 +1,22 @@
{
"name": "game-icons",
"style": "css/game-icons.css",
"sass": "scss/game-icons.scss",
"version": "0.1.1",
"repository": {
"type": "git",
"url": "git://github.com/nagoshiashumari/game-icons.git"
},
"devDependencies": {
"grunt": "^1.6.1",
"grunt-contrib-cssmin": "^5.0.0",
"grunt-webfont": "^1.7.2"
},
"scripts": {
"build": "grunt",
"test": "grunt scsslint"
},
"dependencies": {
"grunt-cli": "^1.5.0"
}
"name": "game-icons",
"style": "css/game-icons.css",
"sass": "scss/game-icons.scss",
"version": "0.1.1",
"repository": {
"type": "git",
"url": "git://github.com/nagoshiashumari/game-icons.git"
},
"devDependencies": {
"grunt": "^1.6.1",
"grunt-contrib-cssmin": "^5.0.0",
"grunt-webfont": "^1.7.2"
},
"scripts": {
"build": "grunt",
"test": "grunt scsslint"
},
"dependencies": {
"grunt-cli": "^1.5.0"
}
}

View File

@@ -2,3 +2,4 @@
.DS_Store
*.log*

View File

@@ -1,58 +1,63 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'emerald',
secondary: 'fuchsia',
neutral: 'zinc'
},
footer: {
slots: {
root: 'border-t border-default',
left: 'text-sm text-muted'
}
}
},
seo: {
siteName: 'Kompose'
},
header: {
title: '',
to: '/',
logo: {
alt: '',
light: '',
dark: ''
},
search: true,
colorMode: true,
links: [{
'icon': 'i-simple-icons-github',
'to': 'https://github.com/nuxt-ui-templates/docs',
'target': '_blank',
'aria-label': 'GitHub'
}]
},
footer: {
credits: `kompose © Valknar ${new Date().getFullYear()}`,
colorMode: false,
links: [{
'icon': 'i-simple-icons-x',
'to': 'https://x.com/bordeaux1981',
'target': '_blank',
'aria-label': 'Nuxt on X'
}, {
'icon': 'i-simple-icons-github',
'to': 'https://github.com/valknarogg',
'target': '_blank',
'aria-label': 'Valknar on GitHub'
}]
},
toc: {
title: 'Table of Contents',
bottom: {
title: 'Community',
edit: 'https://code.pivoine.art/valknar/kompose/src/branch/main/docs/content',
links: []
}
}
})
ui: {
colors: {
primary: "emerald",
secondary: "fuchsia",
neutral: "zinc",
},
footer: {
slots: {
root: "border-t border-default",
left: "text-sm text-muted",
},
},
},
seo: {
siteName: "Kompose",
},
header: {
title: "",
to: "/",
logo: {
alt: "",
light: "",
dark: "",
},
search: true,
colorMode: true,
links: [
{
icon: "i-simple-icons-github",
to: "https://github.com/nuxt-ui-templates/docs",
target: "_blank",
"aria-label": "GitHub",
},
],
},
footer: {
credits: `kompose © Valknar ${new Date().getFullYear()}`,
colorMode: false,
links: [
{
icon: "i-simple-icons-x",
to: "https://x.com/bordeaux1981",
target: "_blank",
"aria-label": "Nuxt on X",
},
{
icon: "i-simple-icons-github",
to: "https://github.com/valknarogg",
target: "_blank",
"aria-label": "Valknar on GitHub",
},
],
},
toc: {
title: "Table of Contents",
bottom: {
title: "Community",
edit: "https://code.pivoine.art/valknar/kompose/src/branch/main/docs/content",
links: [],
},
},
});

View File

@@ -1,27 +1,31 @@
<script setup lang="ts">
const { seo } = useAppConfig()
const { seo } = useAppConfig();
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
const { data: navigation } = await useAsyncData("navigation", () =>
queryCollectionNavigation("docs"),
);
const { data: files } = useLazyAsyncData(
"search",
() => queryCollectionSearchSections("docs"),
{
server: false,
},
);
useHead({
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
htmlAttrs: {
lang: 'en'
}
})
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
htmlAttrs: {
lang: "en",
},
});
useSeoMeta({
titleTemplate: `%s - ${seo?.siteName}`,
ogSiteName: seo?.siteName,
twitterCard: 'summary_large_image'
})
titleTemplate: `%s - ${seo?.siteName}`,
ogSiteName: seo?.siteName,
twitterCard: "summary_large_image",
});
provide('navigation', navigation)
provide("navigation", navigation);
</script>
<template>

View File

@@ -4,30 +4,30 @@
@source "../../../content/**/*";
@theme static {
--container-8xl: 90rem;
--font-sans: 'Public Sans', sans-serif;
--container-8xl: 90rem;
--font-sans: "Public Sans", sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
--color-green-50: #effdf5;
--color-green-100: #d9fbe8;
--color-green-200: #b3f5d1;
--color-green-300: #75edae;
--color-green-400: #00dc82;
--color-green-500: #00c16a;
--color-green-600: #00a155;
--color-green-700: #007f45;
--color-green-800: #016538;
--color-green-900: #0a5331;
--color-green-950: #052e16;
}
:root {
--ui-container: var(--container-8xl);
--ui-container: var(--container-8xl);
}
h2 > a > span + span {
@apply size-6 align-text-top;
@apply size-6 align-text-top;
}
h3 > a > span + span {
@apply size-5 align-text-top;
@apply size-5 align-text-top;
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
const { footer } = useAppConfig()
const { footer } = useAppConfig();
</script>
<template>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import type { ContentNavigationItem } from "@nuxt/content";
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
const { header } = useAppConfig()
const { header } = useAppConfig();
</script>
<template>

View File

@@ -75,50 +75,50 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref } from "vue";
interface Props {
size?: string
interactive?: boolean
size?: string;
interactive?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: '192px',
interactive: true
})
size: "192px",
interactive: true,
});
const isClicked = ref(false)
const showRipple = ref(false)
const isClicked = ref(false);
const showRipple = ref(false);
const handleClick = () => {
if (!props.interactive) return
if (!props.interactive) return;
isClicked.value = true
showRipple.value = true
isClicked.value = true;
showRipple.value = true;
setTimeout(() => {
isClicked.value = false
}, 600)
setTimeout(() => {
isClicked.value = false;
}, 600);
setTimeout(() => {
showRipple.value = false
}, 800)
}
setTimeout(() => {
showRipple.value = false;
}, 800);
};
const handleHover = () => {
if (!props.interactive) return
// Hover animations are handled by CSS
}
if (!props.interactive) return;
// Hover animations are handled by CSS
};
const handleLeave = () => {
if (!props.interactive) return
// Leave animations are handled by CSS
}
if (!props.interactive) return;
// Leave animations are handled by CSS
};
const handleTouch = (e: TouchEvent) => {
if (!props.interactive) return
handleClick()
}
if (!props.interactive) return;
handleClick();
};
</script>
<style scoped>

View File

@@ -6,7 +6,7 @@
-->
<script setup>
import AppIcon from './AppIcon.vue'
import AppIcon from "./AppIcon.vue";
</script>
<template>

View File

@@ -1,21 +1,22 @@
<script setup>
import { ref, computed } from 'vue'
import { ref, computed } from "vue";
const props = defineProps({
size: {
type: String,
default: '42px' // Can be: '24px', '32px', '42px', '56px', etc.
}
})
size: {
type: String,
default: "42px", // Can be: '24px', '32px', '42px', '56px', etc.
},
});
const isHovered = ref(false)
const isHovered = ref(false);
// Load Google Font
if (typeof document !== 'undefined') {
const link = document.createElement('link')
link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@800;900&display=swap'
link.rel = 'stylesheet'
document.head.appendChild(link)
if (typeof document !== "undefined") {
const link = document.createElement("link");
link.href =
"https://fonts.googleapis.com/css2?family=Inter:wght@800;900&display=swap";
link.rel = "stylesheet";
document.head.appendChild(link);
}
</script>

View File

@@ -1,11 +1,14 @@
<script lang="ts" setup>
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
title: 'title',
description: 'description'
})
const props = withDefaults(
defineProps<{ title?: string; description?: string; headline?: string }>(),
{
title: "title",
description: "description",
},
);
const title = computed(() => (props.title || '').slice(0, 60))
const description = computed(() => (props.description || '').slice(0, 200))
const title = computed(() => (props.title || "").slice(0, 60));
const description = computed(() => (props.description || "").slice(0, 200));
</script>
<template>

View File

@@ -1,51 +1,51 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { useClipboard } from "@vueuse/core";
const route = useRoute()
const toast = useToast()
const { copy, copied } = useClipboard()
const site = useSiteConfig()
const isCopying = ref(false)
console.log(site)
const route = useRoute();
const toast = useToast();
const { copy, copied } = useClipboard();
const site = useSiteConfig();
const isCopying = ref(false);
console.log(site);
const mdPath = computed(() => `${site.url}/raw${route.path}.md`)
const mdPath = computed(() => `${site.url}/raw${route.path}.md`);
const items = [
{
label: 'Copy Markdown link',
icon: 'i-lucide-link',
onSelect() {
copy(mdPath.value)
toast.add({
title: 'Copied to clipboard',
icon: 'i-lucide-check-circle'
})
}
},
{
label: 'View as Markdown',
icon: 'i-simple-icons:markdown',
target: '_blank',
to: `/raw${route.path}.md`
},
{
label: 'Open in ChatGPT',
icon: 'i-simple-icons:openai',
target: '_blank',
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
},
{
label: 'Open in Claude',
icon: 'i-simple-icons:anthropic',
target: '_blank',
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
}
]
{
label: "Copy Markdown link",
icon: "i-lucide-link",
onSelect() {
copy(mdPath.value);
toast.add({
title: "Copied to clipboard",
icon: "i-lucide-check-circle",
});
},
},
{
label: "View as Markdown",
icon: "i-simple-icons:markdown",
target: "_blank",
to: `/raw${route.path}.md`,
},
{
label: "Open in ChatGPT",
icon: "i-simple-icons:openai",
target: "_blank",
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`,
},
{
label: "Open in Claude",
icon: "i-simple-icons:anthropic",
target: "_blank",
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`,
},
];
async function copyPage() {
isCopying.value = true
copy(await $fetch<string>(`/raw${route.path}.md`))
isCopying.value = false
isCopying.value = true;
copy(await $fetch<string>(`/raw${route.path}.md`));
isCopying.value = false;
}
</script>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
const { isLoading } = useLoadingIndicator()
const { isLoading } = useLoadingIndicator();
const appear = ref(false)
const appeared = ref(false)
const appear = ref(false);
const appeared = ref(false);
onMounted(() => {
setTimeout(() => {
appear.value = true
setTimeout(() => {
appeared.value = true
}, 1000)
}, 0)
})
setTimeout(() => {
appear.value = true;
setTimeout(() => {
appeared.value = true;
}, 1000);
}, 0);
});
</script>
<template>

View File

@@ -1,58 +1,67 @@
<script setup lang="ts">
interface Star {
x: number
y: number
size: number
x: number;
y: number;
size: number;
}
const props = withDefaults(defineProps<{
starCount?: number
color?: string
speed?: 'slow' | 'normal' | 'fast'
size?: { min: number, max: number }
}>(), {
starCount: 300,
color: 'var(--ui-primary)',
speed: 'normal',
size: () => ({
min: 1,
max: 2
})
})
const props = withDefaults(
defineProps<{
starCount?: number;
color?: string;
speed?: "slow" | "normal" | "fast";
size?: { min: number; max: number };
}>(),
{
starCount: 300,
color: "var(--ui-primary)",
speed: "normal",
size: () => ({
min: 1,
max: 2,
}),
},
);
// Generate random star positions and sizes
const generateStars = (count: number): Star[] => {
return Array.from({ length: count }, () => ({
x: Math.floor(Math.random() * 2000),
y: Math.floor(Math.random() * 2000),
size: typeof props.size === 'number'
? props.size
: Math.random() * (props.size.max - props.size.min) + props.size.min
}))
}
return Array.from({ length: count }, () => ({
x: Math.floor(Math.random() * 2000),
y: Math.floor(Math.random() * 2000),
size:
typeof props.size === "number"
? props.size
: Math.random() * (props.size.max - props.size.min) + props.size.min,
}));
};
// Define speed configurations once
const speedMap = {
slow: { duration: 200, opacity: 0.5, ratio: 0.3 },
normal: { duration: 150, opacity: 0.75, ratio: 0.3 },
fast: { duration: 100, opacity: 1, ratio: 0.4 }
}
slow: { duration: 200, opacity: 0.5, ratio: 0.3 },
normal: { duration: 150, opacity: 0.75, ratio: 0.3 },
fast: { duration: 100, opacity: 1, ratio: 0.4 },
};
// Use a more efficient approach to generate and store stars
const stars = useState<{ slow: Star[], normal: Star[], fast: Star[] }>('stars', () => {
return {
slow: generateStars(Math.floor(props.starCount * speedMap.slow.ratio)),
normal: generateStars(Math.floor(props.starCount * speedMap.normal.ratio)),
fast: generateStars(Math.floor(props.starCount * speedMap.fast.ratio))
}
})
const stars = useState<{ slow: Star[]; normal: Star[]; fast: Star[] }>(
"stars",
() => {
return {
slow: generateStars(Math.floor(props.starCount * speedMap.slow.ratio)),
normal: generateStars(
Math.floor(props.starCount * speedMap.normal.ratio),
),
fast: generateStars(Math.floor(props.starCount * speedMap.fast.ratio)),
};
},
);
// Compute star layers with different speeds and opacities
const starLayers = computed(() => [
{ stars: stars.value.fast, ...speedMap.fast },
{ stars: stars.value.normal, ...speedMap.normal },
{ stars: stars.value.slow, ...speedMap.slow }
])
{ stars: stars.value.fast, ...speedMap.fast },
{ stars: stars.value.normal, ...speedMap.normal },
{ stars: stars.value.slow, ...speedMap.slow },
]);
</script>
<template>

View File

@@ -1,27 +1,33 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
import type { NuxtError } from "#app";
defineProps<{
error: NuxtError
}>()
error: NuxtError;
}>();
useHead({
htmlAttrs: {
lang: 'en'
}
})
htmlAttrs: {
lang: "en",
},
});
useSeoMeta({
title: 'Page not found',
description: 'We are sorry but this page could not be found.'
})
title: "Page not found",
description: "We are sorry but this page could not be found.",
});
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
server: false
})
const { data: navigation } = await useAsyncData("navigation", () =>
queryCollectionNavigation("docs"),
);
const { data: files } = useLazyAsyncData(
"search",
() => queryCollectionSearchSections("docs"),
{
server: false,
},
);
provide('navigation', navigation)
provide("navigation", navigation);
</script>
<template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import type { ContentNavigationItem } from "@nuxt/content";
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
</script>
<template>

View File

@@ -1,55 +1,63 @@
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content'
import { findPageHeadline } from '@nuxt/content/utils'
import type { ContentNavigationItem } from "@nuxt/content";
import { findPageHeadline } from "@nuxt/content/utils";
definePageMeta({
layout: 'docs'
})
layout: "docs",
});
const route = useRoute()
const { toc } = useAppConfig()
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
const route = useRoute();
const { toc } = useAppConfig();
const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
const { data: page } = await useAsyncData(route.path, () =>
queryCollection("docs").path(route.path).first(),
);
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
throw createError({
statusCode: 404,
statusMessage: "Page not found",
fatal: true,
});
}
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
return queryCollectionItemSurroundings('docs', route.path, {
fields: ['description']
})
})
return queryCollectionItemSurroundings("docs", route.path, {
fields: ["description"],
});
});
const title = page.value.seo?.title || page.value.title
const description = page.value.seo?.description || page.value.description
const title = page.value.seo?.title || page.value.title;
const description = page.value.seo?.description || page.value.description;
useSeoMeta({
title,
ogTitle: title,
description,
ogDescription: description
})
title,
ogTitle: title,
description,
ogDescription: description,
});
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
const headline = computed(() =>
findPageHeadline(navigation?.value, page.value?.path),
);
defineOgImageComponent('Docs', {
headline: headline.value
})
defineOgImageComponent("Docs", {
headline: headline.value,
});
const links = computed(() => {
const links = []
if (toc?.bottom?.edit) {
links.push({
icon: 'i-lucide-external-link',
label: 'Edit this page',
to: `${toc.bottom.edit}/${page?.value?.stem}.${page?.value?.extension}`,
target: '_blank'
})
}
const links = [];
if (toc?.bottom?.edit) {
links.push({
icon: "i-lucide-external-link",
label: "Edit this page",
to: `${toc.bottom.edit}/${page?.value?.stem}.${page?.value?.extension}`,
target: "_blank",
});
}
return [...links, ...(toc?.bottom?.links || [])].filter(Boolean)
})
return [...links, ...(toc?.bottom?.links || [])].filter(Boolean);
});
</script>
<template>

View File

@@ -1,24 +1,28 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
import { defineContentConfig, defineCollection, z } from "@nuxt/content";
export default defineContentConfig({
collections: {
landing: defineCollection({
type: 'page',
source: 'index.md'
}),
docs: defineCollection({
type: 'page',
source: {
include: '**',
},
schema: z.object({
links: z.array(z.object({
label: z.string(),
icon: z.string(),
to: z.string(),
target: z.string().optional()
})).optional()
})
})
}
})
collections: {
landing: defineCollection({
type: "page",
source: "index.md",
}),
docs: defineCollection({
type: "page",
source: {
include: "**",
},
schema: z.object({
links: z
.array(
z.object({
label: z.string(),
icon: z.string(),
to: z.string(),
target: z.string().optional(),
}),
)
.optional(),
}),
}),
},
});

View File

@@ -1,6 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
import withNuxt from "./.nuxt/eslint.config.mjs";
export default withNuxt(
// Your custom configs here
)
// Your custom configs here
);

View File

@@ -1,103 +1,102 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
app: {
baseURL: '/kompose/',
},
modules: [
'@nuxt/eslint',
'@nuxt/image',
'@nuxt/ui',
'@nuxt/content',
'nuxt-og-image',
'nuxt-llms'
],
app: {
baseURL: "/kompose/",
},
modules: [
"@nuxt/eslint",
"@nuxt/image",
"@nuxt/ui",
"@nuxt/content",
"nuxt-og-image",
"nuxt-llms",
],
// content: {
// build: {
// markdown: {
// // Object syntax can be used to override default options
// remarkPlugins: {
// // Override remark-emoji options
// 'remark-emoji': {
// options: {
// emoticon: true
// }
// },
// // Disable remark-gfm
// 'remark-gfm': false,
// // Add remark-oembed
// 'remark-oembed': {
// // Options
// }
// },
// }
// }
// },
// content: {
// build: {
// markdown: {
// // Object syntax can be used to override default options
// remarkPlugins: {
// // Override remark-emoji options
// 'remark-emoji': {
// options: {
// emoticon: true
// }
// },
// // Disable remark-gfm
// 'remark-gfm': false,
// // Add remark-oembed
// 'remark-oembed': {
// // Options
// }
// },
// }
// }
// },
devtools: {
enabled: false
},
devtools: {
enabled: false,
},
css: ['~/assets/css/main.css'],
css: ["~/assets/css/main.css"],
content: {
build: {
markdown: {
toc: {
searchDepth: 1
}
}
}
},
content: {
build: {
markdown: {
toc: {
searchDepth: 1,
},
},
},
},
compatibilityDate: '2024-07-11',
compatibilityDate: "2024-07-11",
nitro: {
prerender: {
routes: [
'/'
],
crawlLinks: true,
autoSubfolderIndex: false
}
},
nitro: {
prerender: {
routes: ["/"],
crawlLinks: true,
autoSubfolderIndex: false,
},
},
eslint: {
config: {
stylistic: {
commaDangle: 'never',
braceStyle: '1tbs'
}
}
},
eslint: {
config: {
stylistic: {
commaDangle: "never",
braceStyle: "1tbs",
},
},
},
icon: {
provider: 'iconify'
},
icon: {
provider: "iconify",
},
llms: {
domain: 'https://docs-template.nuxt.dev/',
title: 'Nuxt Docs Template',
description: 'A template for building documentation with Nuxt UI and Nuxt Content.',
full: {
title: 'Nuxt Docs Template - Full Documentation',
description: 'This is the full documentation for the Nuxt Docs Template.'
},
sections: [
{
title: 'Getting Started',
contentCollection: 'docs',
contentFilters: [
{ field: 'path', operator: 'LIKE', value: '/getting-started%' }
]
},
{
title: 'Essentials',
contentCollection: 'docs',
contentFilters: [
{ field: 'path', operator: 'LIKE', value: '/essentials%' }
]
}
]
}
})
llms: {
domain: "https://docs-template.nuxt.dev/",
title: "Nuxt Docs Template",
description:
"A template for building documentation with Nuxt UI and Nuxt Content.",
full: {
title: "Nuxt Docs Template - Full Documentation",
description: "This is the full documentation for the Nuxt Docs Template.",
},
sections: [
{
title: "Getting Started",
contentCollection: "docs",
contentFilters: [
{ field: "path", operator: "LIKE", value: "/getting-started%" },
],
},
{
title: "Essentials",
contentCollection: "docs",
contentFilters: [
{ field: "path", operator: "LIKE", value: "/essentials%" },
],
},
],
},
});

View File

@@ -1,40 +1,40 @@
{
"name": "nuxt-ui-template-docs",
"private": true,
"type": "module",
"scripts": {
"generate": "nuxi generate",
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.68",
"@iconify-json/simple-icons": "^1.2.54",
"@iconify-json/vscode-icons": "^1.2.30",
"@nuxt/content": "^3.7.1",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.0.1",
"@nuxtjs/mdc": "^0.17.4",
"@tailwindcss/typography": "^0.5.19",
"@vite-pwa/nuxt": "^1.0.4",
"better-sqlite3": "^12.4.1",
"nuxt": "^4.1.2",
"nuxt-llms": "0.1.3",
"nuxt-og-image": "^5.1.11",
"tailwindcss": "^4.1.14",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"eslint": "^9.37.0",
"typescript": "^5.9.3",
"vue-tsc": "^3.1.0"
},
"resolutions": {
"unimport": "4.1.1"
}
"name": "nuxt-ui-template-docs",
"private": true,
"type": "module",
"scripts": {
"generate": "nuxi generate",
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.68",
"@iconify-json/simple-icons": "^1.2.54",
"@iconify-json/vscode-icons": "^1.2.30",
"@nuxt/content": "^3.7.1",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.0.1",
"@nuxtjs/mdc": "^0.17.4",
"@tailwindcss/typography": "^0.5.19",
"@vite-pwa/nuxt": "^1.0.4",
"better-sqlite3": "^12.4.1",
"nuxt": "^4.1.2",
"nuxt-llms": "0.1.3",
"nuxt-og-image": "^5.1.11",
"tailwindcss": "^4.1.14",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"eslint": "^9.37.0",
"typescript": "^5.9.3",
"vue-tsc": "^3.1.0"
},
"resolutions": {
"unimport": "4.1.1"
}
}

View File

@@ -1,13 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}],
"postUpdateOptions": ["pnpmDedupe"]
"extends": ["github>nuxt/renovate-config-nuxt"],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [
{
"matchDepTypes": ["resolutions"],
"enabled": false
}
],
"postUpdateOptions": ["pnpmDedupe"]
}

View File

@@ -1,27 +1,40 @@
import { withLeadingSlash } from 'ufo'
import { stringify } from 'minimark/stringify'
import { queryCollection } from '@nuxt/content/nitro'
import type { Collections } from '@nuxt/content'
import { withLeadingSlash } from "ufo";
import { stringify } from "minimark/stringify";
import { queryCollection } from "@nuxt/content/nitro";
import type { Collections } from "@nuxt/content";
export default eventHandler(async (event) => {
const slug = getRouterParams(event)['slug.md']
if (!slug?.endsWith('.md')) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
const slug = getRouterParams(event)["slug.md"];
if (!slug?.endsWith(".md")) {
throw createError({
statusCode: 404,
statusMessage: "Page not found",
fatal: true,
});
}
const path = withLeadingSlash(slug.replace('.md', ''))
const path = withLeadingSlash(slug.replace(".md", ""));
const page = await queryCollection(event, 'docs' as keyof Collections).path(path).first()
if (!page) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
const page = await queryCollection(event, "docs" as keyof Collections)
.path(path)
.first();
if (!page) {
throw createError({
statusCode: 404,
statusMessage: "Page not found",
fatal: true,
});
}
// Add title and description to the top of the page if missing
if (page.body.value[0]?.[0] !== 'h1') {
page.body.value.unshift(['blockquote', {}, page.description])
page.body.value.unshift(['h1', {}, page.title])
}
// Add title and description to the top of the page if missing
if (page.body.value[0]?.[0] !== "h1") {
page.body.value.unshift(["blockquote", {}, page.description]);
page.body.value.unshift(["h1", {}, page.title]);
}
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
return stringify({ ...page.body, type: 'minimark' }, { format: 'markdown/html' })
})
setHeader(event, "Content-Type", "text/markdown; charset=utf-8");
return stringify(
{ ...page.body, type: "minimark" },
{ format: "markdown/html" },
);
});

View File

@@ -1,4 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View File

@@ -1,15 +1,15 @@
{
"eslint.useFlatConfig": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"typescript.tsdk": "node_modules/typescript/lib"
"eslint.useFlatConfig": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -1,25 +1,25 @@
import globals from "globals"
import pluginJs from "@eslint/js"
import * as tseslint from "typescript-eslint"
import globals from "globals";
import pluginJs from "@eslint/js";
import * as tseslint from "typescript-eslint";
export default tseslint.config({
files: ["src/**/*.{js,mjs,cjs,ts}"],
extends: [pluginJs.configs.recommended],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: tseslint.parser,
parserOptions: {
project: true,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
"no-unused-vars": "off",
},
})
files: ["src/**/*.{js,mjs,cjs,ts}"],
extends: [pluginJs.configs.recommended],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: tseslint.parser,
parserOptions: {
project: true,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
"no-unused-vars": "off",
},
});

View File

@@ -1,71 +1,71 @@
{
"name": "backend",
"version": "0.1.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"start": "bun run src/index.ts",
"dev": "watchexec -r -e ts bun run src/index.ts",
"build": "rm -rf dist && tsc -b tsconfig.build.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"generate": "prisma generate",
"generate:sql": "prisma generate --sql && pnpm exec prettier --write prisma/client",
"test": "dotenv -e .env.test -- vitest"
},
"exports": {
".": "./src/index.ts",
"./shared": "./src/shared.ts"
},
"prisma": {
"seed": "bun prisma/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.7.0",
"@trpc/server": "11.0.0-rc.730",
"bcryptjs": "^3.0.0",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"nodemailer": "^6.10.0",
"p-map": "^7.0.3",
"superjson": "^2.2.2",
"uuid": "^11.0.5",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@faker-js/faker": "^9.5.0",
"@repo/eslint-config": "workspace:*",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.3",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.12.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.7",
"@types/uuid": "^10.0.0",
"@vitest/ui": "3.0.5",
"dotenv-cli": "^8.0.0",
"eslint": "^9.19.0",
"globals": "^15.14.0",
"prisma": "^6.7.0",
"supertest": "^7.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.22.0",
"vitest": "^3.0.5"
}
"name": "backend",
"version": "0.1.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"start": "bun run src/index.ts",
"dev": "watchexec -r -e ts bun run src/index.ts",
"build": "rm -rf dist && tsc -b tsconfig.build.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"generate": "prisma generate",
"generate:sql": "prisma generate --sql && pnpm exec prettier --write prisma/client",
"test": "dotenv -e .env.test -- vitest"
},
"exports": {
".": "./src/index.ts",
"./shared": "./src/shared.ts"
},
"prisma": {
"seed": "bun prisma/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.7.0",
"@trpc/server": "11.0.0-rc.730",
"bcryptjs": "^3.0.0",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"nodemailer": "^6.10.0",
"p-map": "^7.0.3",
"superjson": "^2.2.2",
"uuid": "^11.0.5",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@faker-js/faker": "^9.5.0",
"@repo/eslint-config": "workspace:*",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.3",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.12.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.7",
"@types/uuid": "^10.0.0",
"@vitest/ui": "3.0.5",
"dotenv-cli": "^8.0.0",
"eslint": "^9.19.0",
"globals": "^15.14.0",
"prisma": "^6.7.0",
"supertest": "^7.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.22.0",
"vitest": "^3.0.5"
}
}

View File

@@ -1,19 +1,19 @@
export interface $DbEnums {}
export namespace $DbEnums {
type CampaignStatus =
| "DRAFT"
| "SCHEDULED"
| "SENDING"
| "COMPLETED"
| "CANCELLED";
type MessageStatus =
| "QUEUED"
| "PENDING"
| "SENT"
| "OPENED"
| "CLICKED"
| "FAILED"
| "RETRYING";
type SmtpEncryption = "STARTTLS" | "SSL_TLS" | "NONE";
type CampaignStatus =
| "DRAFT"
| "SCHEDULED"
| "SENDING"
| "COMPLETED"
| "CANCELLED";
type MessageStatus =
| "QUEUED"
| "PENDING"
| "SENT"
| "OPENED"
| "CLICKED"
| "FAILED"
| "RETRYING";
type SmtpEncryption = "STARTTLS" | "SSL_TLS" | "NONE";
}

View File

@@ -4,19 +4,19 @@ import * as $runtime from "../runtime/library";
* @param text
*/
export const countDbSize: (
text: string,
text: string,
) => $runtime.TypedSql<countDbSize.Parameters, countDbSize.Result>;
export namespace countDbSize {
export type Parameters = [text: string];
export type Result = {
organization_id: string;
organization_name: string;
campaign_count: bigint | null;
template_count: bigint | null;
message_count: bigint | null;
subscriber_count: bigint | null;
list_count: bigint | null;
total_size_mb: $runtime.Decimal | null;
};
export type Parameters = [text: string];
export type Result = {
organization_id: string;
organization_name: string;
campaign_count: bigint | null;
template_count: bigint | null;
message_count: bigint | null;
subscriber_count: bigint | null;
list_count: bigint | null;
total_size_mb: $runtime.Decimal | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -4,15 +4,15 @@ import * as $runtime from "../runtime/library";
* @param text
*/
export const countDistinctRecipients: (
text: string,
text: string,
) => $runtime.TypedSql<
countDistinctRecipients.Parameters,
countDistinctRecipients.Result
countDistinctRecipients.Parameters,
countDistinctRecipients.Result
>;
export namespace countDistinctRecipients {
export type Parameters = [text: string];
export type Result = {
count: bigint | null;
};
export type Parameters = [text: string];
export type Result = {
count: bigint | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -6,17 +6,17 @@ import * as $runtime from "../runtime/library";
* @param timestamp
*/
export const countDistinctRecipientsInTimeRange: (
text: string,
timestamp: Date,
timestamp: Date,
text: string,
timestamp: Date,
timestamp: Date,
) => $runtime.TypedSql<
countDistinctRecipientsInTimeRange.Parameters,
countDistinctRecipientsInTimeRange.Result
countDistinctRecipientsInTimeRange.Parameters,
countDistinctRecipientsInTimeRange.Result
>;
export namespace countDistinctRecipientsInTimeRange {
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
count: bigint | null;
};
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
count: bigint | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -3,8 +3,8 @@
"use strict";
exports.countDbSize = require("./countDbSize.edge.js").countDbSize;
exports.countDistinctRecipients =
require("./countDistinctRecipients.edge.js").countDistinctRecipients;
require("./countDistinctRecipients.edge.js").countDistinctRecipients;
exports.countDistinctRecipientsInTimeRange =
require("./countDistinctRecipientsInTimeRange.edge.js").countDistinctRecipientsInTimeRange;
require("./countDistinctRecipientsInTimeRange.edge.js").countDistinctRecipientsInTimeRange;
exports.subscriberGrowthQuery =
require("./subscriberGrowthQuery.edge.js").subscriberGrowthQuery;
require("./subscriberGrowthQuery.edge.js").subscriberGrowthQuery;

View File

@@ -3,8 +3,8 @@
"use strict";
exports.countDbSize = require("./countDbSize.js").countDbSize;
exports.countDistinctRecipients =
require("./countDistinctRecipients.js").countDistinctRecipients;
require("./countDistinctRecipients.js").countDistinctRecipients;
exports.countDistinctRecipientsInTimeRange =
require("./countDistinctRecipientsInTimeRange.js").countDistinctRecipientsInTimeRange;
require("./countDistinctRecipientsInTimeRange.js").countDistinctRecipientsInTimeRange;
exports.subscriberGrowthQuery =
require("./subscriberGrowthQuery.js").subscriberGrowthQuery;
require("./subscriberGrowthQuery.js").subscriberGrowthQuery;

View File

@@ -6,18 +6,18 @@ import * as $runtime from "../runtime/library";
* @param timestamp
*/
export const subscriberGrowthQuery: (
text: string,
timestamp: Date,
timestamp: Date,
text: string,
timestamp: Date,
timestamp: Date,
) => $runtime.TypedSql<
subscriberGrowthQuery.Parameters,
subscriberGrowthQuery.Result
subscriberGrowthQuery.Parameters,
subscriberGrowthQuery.Result
>;
export namespace subscriberGrowthQuery {
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
date: Date | null;
count: bigint | null;
};
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
date: Date | null;
count: bigint | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -1,99 +1,99 @@
import { hashPassword } from "../src/utils/auth"
import { prisma } from "../src/utils/prisma"
import { SmtpEncryption } from "./client"
import dayjs from "dayjs"
import { hashPassword } from "../src/utils/auth";
import { prisma } from "../src/utils/prisma";
import { SmtpEncryption } from "./client";
import dayjs from "dayjs";
async function seed() {
if (!(await prisma.organization.findFirst())) {
await prisma.organization.create({
data: {
name: "Test Organization",
description: "Test Description",
GeneralSettings: {
create: {},
},
EmailDeliverySettings: {
create: {
rateLimit: 100,
},
},
SmtpSettings: {
create: {
host: "smtp.test.com",
port: 587,
username: "test",
password: "test",
encryption: SmtpEncryption.STARTTLS,
},
},
},
})
}
if (!(await prisma.organization.findFirst())) {
await prisma.organization.create({
data: {
name: "Test Organization",
description: "Test Description",
GeneralSettings: {
create: {},
},
EmailDeliverySettings: {
create: {
rateLimit: 100,
},
},
SmtpSettings: {
create: {
host: "smtp.test.com",
port: 587,
username: "test",
password: "test",
encryption: SmtpEncryption.STARTTLS,
},
},
},
});
}
const orgId = (
await prisma.organization.findFirst({
orderBy: {
createdAt: "asc",
},
})
)?.id
const orgId = (
await prisma.organization.findFirst({
orderBy: {
createdAt: "asc",
},
})
)?.id;
if (!orgId) {
throw new Error("not reachable")
}
if (!orgId) {
throw new Error("not reachable");
}
if (!(await prisma.user.findFirst())) {
await prisma.user.create({
data: {
name: "Admin",
email: "admin@example.com",
password: await hashPassword("password123"),
UserOrganizations: {
create: {
organizationId: orgId,
},
},
},
})
}
if (!(await prisma.user.findFirst())) {
await prisma.user.create({
data: {
name: "Admin",
email: "admin@example.com",
password: await hashPassword("password123"),
UserOrganizations: {
create: {
organizationId: orgId,
},
},
},
});
}
// Create 5000 subscribers
const subscribers = Array.from({ length: 5000 }, (_, i) => ({
name: `Subscriber ${i + 1}`,
email: `subscriber${i + 1}@example.com`,
organizationId: orgId,
createdAt: dayjs().subtract(12, "days").toDate(),
}))
await prisma.subscriber.createMany({
data: subscribers,
skipDuplicates: true,
})
// Then 10 more for each day for 10 days
const now = new Date()
for (let d = 0; d < 10; d++) {
const day = dayjs(now)
.subtract(d + 1, "day")
.toDate()
// Create 5000 subscribers
const subscribers = Array.from({ length: 5000 }, (_, i) => ({
name: `Subscriber ${i + 1}`,
email: `subscriber${i + 1}@example.com`,
organizationId: orgId,
createdAt: dayjs().subtract(12, "days").toDate(),
}));
await prisma.subscriber.createMany({
data: subscribers,
skipDuplicates: true,
});
// Then 10 more for each day for 10 days
const now = new Date();
for (let d = 0; d < 10; d++) {
const day = dayjs(now)
.subtract(d + 1, "day")
.toDate();
const dailySubs = Array.from({ length: 10 }, (_, i) => ({
name: `DailySub ${d + 1}-${i + 1}`,
email: `dailysub${d + 1}-${i + 1}@example.com`,
organizationId: orgId,
createdAt: day,
updatedAt: day,
}))
await prisma.subscriber.createMany({
data: dailySubs,
skipDuplicates: true,
})
}
const dailySubs = Array.from({ length: 10 }, (_, i) => ({
name: `DailySub ${d + 1}-${i + 1}`,
email: `dailysub${d + 1}-${i + 1}@example.com`,
organizationId: orgId,
createdAt: day,
updatedAt: day,
}));
await prisma.subscriber.createMany({
data: dailySubs,
skipDuplicates: true,
});
}
}
seed()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
})
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
});

View File

@@ -1,46 +1,46 @@
import { prisma } from "../utils/prisma"
import express, { NextFunction } from "express"
import { prisma } from "../utils/prisma";
import express, { NextFunction } from "express";
export const authenticateApiKey = async (
req: express.Request,
res: express.Response,
next: NextFunction
req: express.Request,
res: express.Response,
next: NextFunction,
) => {
const apiKey = req.header("x-api-key")
if (!apiKey) {
res.status(401).json({ error: "Missing API Key" })
return
}
const apiKey = req.header("x-api-key");
if (!apiKey) {
res.status(401).json({ error: "Missing API Key" });
return;
}
try {
const keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
select: { id: true, Organization: true },
})
try {
const keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
select: { id: true, Organization: true },
});
if (!keyRecord) {
res.status(401).json({ error: "Invalid API Key" })
return
}
if (!keyRecord) {
res.status(401).json({ error: "Invalid API Key" });
return;
}
// Update lastUsed timestamp asynchronously, don't await
prisma.apiKey
.update({
where: { id: keyRecord.id },
data: { lastUsed: new Date() },
})
.catch((updateError) => {
// Log the error but don't block the request
console.error(
"Failed to update API key lastUsed timestamp",
updateError
)
})
// Update lastUsed timestamp asynchronously, don't await
prisma.apiKey
.update({
where: { id: keyRecord.id },
data: { lastUsed: new Date() },
})
.catch((updateError) => {
// Log the error but don't block the request
console.error(
"Failed to update API key lastUsed timestamp",
updateError,
);
});
req.organization = keyRecord.Organization
next()
} catch (error) {
console.error("Error validating API key", error)
res.status(500).json({ error: "Server error" })
}
}
req.organization = keyRecord.Organization;
next();
} catch (error) {
console.error("Error validating API key", error);
res.status(500).json({ error: "Server error" });
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,165 +1,165 @@
import * as trpcExpress from "@trpc/server/adapters/express"
import path from "path"
import express from "express"
import cors from "cors"
import { prisma } from "./utils/prisma"
import swaggerUi from "swagger-ui-express"
import * as trpcExpress from "@trpc/server/adapters/express";
import path from "path";
import express from "express";
import cors from "cors";
import { prisma } from "./utils/prisma";
import swaggerUi from "swagger-ui-express";
import { createContext, router } from "./trpc"
import { userRouter } from "./user/router"
import { listRouter } from "./list/router"
import { organizationRouter } from "./organization/router"
import { subscriberRouter } from "./subscriber/router"
import { templateRouter } from "./template/router"
import { campaignRouter } from "./campaign/router"
import { messageRouter } from "./message/router"
import { settingsRouter } from "./settings/router"
import swaggerSpec from "./swagger"
import { apiRouter } from "./api/server"
import { dashboardRouter } from "./dashboard/router"
import { statsRouter } from "./stats/router"
import { ONE_PX_PNG } from "./constants"
import { createContext, router } from "./trpc";
import { userRouter } from "./user/router";
import { listRouter } from "./list/router";
import { organizationRouter } from "./organization/router";
import { subscriberRouter } from "./subscriber/router";
import { templateRouter } from "./template/router";
import { campaignRouter } from "./campaign/router";
import { messageRouter } from "./message/router";
import { settingsRouter } from "./settings/router";
import swaggerSpec from "./swagger";
import { apiRouter } from "./api/server";
import { dashboardRouter } from "./dashboard/router";
import { statsRouter } from "./stats/router";
import { ONE_PX_PNG } from "./constants";
const appRouter = router({
user: userRouter,
list: listRouter,
organization: organizationRouter,
subscriber: subscriberRouter,
template: templateRouter,
campaign: campaignRouter,
message: messageRouter,
settings: settingsRouter,
dashboard: dashboardRouter,
stats: statsRouter,
})
user: userRouter,
list: listRouter,
organization: organizationRouter,
subscriber: subscriberRouter,
template: templateRouter,
campaign: campaignRouter,
message: messageRouter,
settings: settingsRouter,
dashboard: dashboardRouter,
stats: statsRouter,
});
export type AppRouter = typeof appRouter
export type AppRouter = typeof appRouter;
export const app = express()
export const app = express();
app.use(
cors({
origin: ["http://localhost:3000", "http://localhost:4173"],
})
)
app.use(express.json())
cors({
origin: ["http://localhost:3000", "http://localhost:4173"],
}),
);
app.use(express.json());
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec))
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get("/t/:id", async (req, res) => {
try {
const { id } = req.params
const subscriberId = req.query.sid
try {
const { id } = req.params;
const subscriberId = req.query.sid;
const trackedLink = await prisma.trackedLink.findUnique({
where: { id },
})
const trackedLink = await prisma.trackedLink.findUnique({
where: { id },
});
if (!trackedLink) {
res.status(404).send("Link not found")
return
}
if (!trackedLink) {
res.status(404).send("Link not found");
return;
}
res.redirect(trackedLink.url)
res.redirect(trackedLink.url);
if (subscriberId && typeof subscriberId === "string") {
await prisma
.$transaction(async (tx) => {
// add a new click
await tx.click.create({
data: {
subscriberId,
trackedLinkId: trackedLink.id,
},
})
if (subscriberId && typeof subscriberId === "string") {
await prisma
.$transaction(async (tx) => {
// add a new click
await tx.click.create({
data: {
subscriberId,
trackedLinkId: trackedLink.id,
},
});
if (!trackedLink.campaignId) return
if (!trackedLink.campaignId) return;
const message = await tx.message.findFirst({
where: {
campaignId: trackedLink.campaignId,
subscriberId,
status: {
not: "CLICKED",
},
},
})
const message = await tx.message.findFirst({
where: {
campaignId: trackedLink.campaignId,
subscriberId,
status: {
not: "CLICKED",
},
},
});
if (!message) return
if (!message) return;
await tx.message.update({
where: {
id: message.id,
},
data: {
status: "CLICKED",
},
})
})
.catch((error) => {
console.error("Error updating message status", error)
})
}
} catch (error) {
res.status(404).send("Link not found")
}
})
await tx.message.update({
where: {
id: message.id,
},
data: {
status: "CLICKED",
},
});
})
.catch((error) => {
console.error("Error updating message status", error);
});
}
} catch (error) {
res.status(404).send("Link not found");
}
});
app.get("/img/:id/img.png", async (req, res) => {
// Send pixel immediately
const pixel = Buffer.from(ONE_PX_PNG, "base64")
res.setHeader("Content-Type", "image/png")
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")
res.setHeader("Pragma", "no-cache")
res.setHeader("Expires", "0")
res.end(pixel)
// Send pixel immediately
const pixel = Buffer.from(ONE_PX_PNG, "base64");
res.setHeader("Content-Type", "image/png");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
res.end(pixel);
const id = req.params.id
const id = req.params.id;
try {
await prisma.$transaction(async (tx) => {
const message = await tx.message.findUnique({
where: {
id,
Campaign: {
openTracking: true,
},
},
})
try {
await prisma.$transaction(async (tx) => {
const message = await tx.message.findUnique({
where: {
id,
Campaign: {
openTracking: true,
},
},
});
if (!message) {
return
}
if (!message) {
return;
}
if (message.status !== "SENT") return
if (message.status !== "SENT") return;
await tx.message.update({
where: { id },
data: {
status: "OPENED",
},
})
})
} catch (error) {
console.error("Error updating message status", error)
}
})
await tx.message.update({
where: { id },
data: {
status: "OPENED",
},
});
});
} catch (error) {
console.error("Error updating message status", error);
}
});
app.use("/api", apiRouter)
app.use("/api", apiRouter);
app.use(
"/trpc",
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
})
)
"/trpc",
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
}),
);
const staticPath = path.join(__dirname, "..", "..", "web", "dist")
const staticPath = path.join(__dirname, "..", "..", "web", "dist");
// serve SPA content
app.use(express.static(staticPath))
app.use(express.static(staticPath));
app.get("*", (_, res) => {
res.sendFile(path.join(staticPath, "index.html"))
})
res.sendFile(path.join(staticPath, "index.html"));
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,249 +1,249 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { resolveProps } from "../utils/pProps"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
import { resolveProps } from "../utils/pProps";
export const listCampaigns = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const where: Prisma.CampaignWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ title: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
{ subject: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
}
const where: Prisma.CampaignWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ title: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
{ subject: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const [total, campaigns] = await prisma.$transaction([
prisma.campaign.count({ where }),
prisma.campaign.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Template: {
select: {
id: true,
name: true,
},
},
CampaignLists: {
include: {
List: {
select: {
id: true,
name: true,
},
},
},
},
_count: {
select: {
Messages: true,
},
},
},
}),
])
const [total, campaigns] = await prisma.$transaction([
prisma.campaign.count({ where }),
prisma.campaign.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Template: {
select: {
id: true,
name: true,
},
},
CampaignLists: {
include: {
List: {
select: {
id: true,
name: true,
},
},
},
},
_count: {
select: {
Messages: true,
},
},
},
}),
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
campaigns,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
campaigns,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getCampaign = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
id: z.string(),
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const campaign = await prisma.campaign.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
Template: true,
CampaignLists: {
include: {
List: true,
},
},
},
})
const campaign = await prisma.campaign.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
Template: true,
CampaignLists: {
include: {
List: true,
},
},
},
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
}
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
});
}
const listSubscribers = await prisma.listSubscriber.findMany({
where: {
listId: {
in: campaign.CampaignLists.map((cl) => cl.listId),
},
unsubscribedAt: null,
},
select: {
id: true,
},
distinct: ["subscriberId"],
})
const listSubscribers = await prisma.listSubscriber.findMany({
where: {
listId: {
in: campaign.CampaignLists.map((cl) => cl.listId),
},
unsubscribedAt: null,
},
select: {
id: true,
},
distinct: ["subscriberId"],
});
// Add the count to each list for backward compatibility
const campaignWithCounts = {
...campaign,
CampaignLists: await Promise.all(
campaign.CampaignLists.map(async (cl) => {
const count = await prisma.listSubscriber.count({
where: {
listId: cl.listId,
unsubscribedAt: null,
},
})
// Add the count to each list for backward compatibility
const campaignWithCounts = {
...campaign,
CampaignLists: await Promise.all(
campaign.CampaignLists.map(async (cl) => {
const count = await prisma.listSubscriber.count({
where: {
listId: cl.listId,
unsubscribedAt: null,
},
});
return {
...cl,
List: {
...cl.List,
_count: {
ListSubscribers: count,
},
},
}
})
),
// Add the unique subscriber count directly to the campaign object
uniqueRecipientCount: listSubscribers.length,
}
return {
...cl,
List: {
...cl.List,
_count: {
ListSubscribers: count,
},
},
};
}),
),
// Add the unique subscriber count directly to the campaign object
uniqueRecipientCount: listSubscribers.length,
};
const promises = {
totalMessages: prisma.message.count({
where: {
campaignId: campaign.id,
},
}),
queuedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "QUEUED",
},
}),
pendingMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "PENDING",
},
}),
sentMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
failedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "FAILED",
},
}),
processed: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
not: "QUEUED",
},
},
}),
clicked: prisma.message.count({
where: {
campaignId: campaign.id,
status: "CLICKED",
},
}),
opened: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["OPENED", "CLICKED"],
},
},
}),
}
const promises = {
totalMessages: prisma.message.count({
where: {
campaignId: campaign.id,
},
}),
queuedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "QUEUED",
},
}),
pendingMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "PENDING",
},
}),
sentMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
failedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "FAILED",
},
}),
processed: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
not: "QUEUED",
},
},
}),
clicked: prisma.message.count({
where: {
campaignId: campaign.id,
status: "CLICKED",
},
}),
opened: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["OPENED", "CLICKED"],
},
},
}),
};
const result = await resolveProps(promises)
const result = await resolveProps(promises);
return {
campaign: campaignWithCounts,
stats: {
totalMessages: result.totalMessages,
queuedMessages: result.queuedMessages,
pendingMessages: result.pendingMessages,
sentMessages: result.sentMessages,
failedMessages: result.failedMessages,
processed: result.processed,
clicked: result.clicked,
opened: result.opened,
clickRate:
result.sentMessages > 0
? (result.clicked / result.sentMessages) * 100
: 0,
openRate:
result.sentMessages > 0
? (result.opened / result.sentMessages) * 100
: 0,
},
}
})
return {
campaign: campaignWithCounts,
stats: {
totalMessages: result.totalMessages,
queuedMessages: result.queuedMessages,
pendingMessages: result.pendingMessages,
sentMessages: result.sentMessages,
failedMessages: result.failedMessages,
processed: result.processed,
clicked: result.clicked,
opened: result.opened,
clickRate:
result.sentMessages > 0
? (result.clicked / result.sentMessages) * 100
: 0,
openRate:
result.sentMessages > 0
? (result.opened / result.sentMessages) * 100
: 0,
},
};
});

View File

@@ -1,23 +1,23 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
createCampaign,
updateCampaign,
deleteCampaign,
startCampaign,
cancelCampaign,
sendTestEmail,
duplicateCampaign,
} from "./mutation"
import { getCampaign, listCampaigns } from "./query"
createCampaign,
updateCampaign,
deleteCampaign,
startCampaign,
cancelCampaign,
sendTestEmail,
duplicateCampaign,
} from "./mutation";
import { getCampaign, listCampaigns } from "./query";
export const campaignRouter = router({
create: createCampaign,
update: updateCampaign,
delete: deleteCampaign,
get: getCampaign,
list: listCampaigns,
start: startCampaign,
cancel: cancelCampaign,
sendTestEmail,
duplicate: duplicateCampaign,
})
create: createCampaign,
update: updateCampaign,
delete: deleteCampaign,
get: getCampaign,
list: listCampaigns,
start: startCampaign,
cancel: cancelCampaign,
sendTestEmail,
duplicate: duplicateCampaign,
});

View File

@@ -1,11 +1,11 @@
import { z } from "zod"
import { z } from "zod";
export const env = z
.object({
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
})
.parse(process.env)
.object({
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
})
.parse(process.env);
export const ONE_PX_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";

View File

@@ -1,57 +1,57 @@
import cron from "node-cron"
import { sendMessagesCron } from "./sendMessages"
import { dailyMaintenanceCron } from "./dailyMaintenance"
import { processQueuedCampaigns } from "./processQueuedCampaigns"
import cron from "node-cron";
import { sendMessagesCron } from "./sendMessages";
import { dailyMaintenanceCron } from "./dailyMaintenance";
import { processQueuedCampaigns } from "./processQueuedCampaigns";
type CronJob = {
name: string
schedule: string
job: () => Promise<void>
enabled: boolean
}
name: string;
schedule: string;
job: () => Promise<void>;
enabled: boolean;
};
const sendMessagesJob: CronJob = {
name: "send-queued-messages",
schedule: "*/5 * * * * *", // Runs every 5 seconds
job: sendMessagesCron,
enabled: true,
}
name: "send-queued-messages",
schedule: "*/5 * * * * *", // Runs every 5 seconds
job: sendMessagesCron,
enabled: true,
};
const dailyMaintenanceJob: CronJob = {
name: "daily-maintenance",
schedule: "0 0 * * *", // Runs daily at midnight
job: dailyMaintenanceCron,
enabled: true,
}
name: "daily-maintenance",
schedule: "0 0 * * *", // Runs daily at midnight
job: dailyMaintenanceCron,
enabled: true,
};
const processQueuedCampaignsJob: CronJob = {
name: "process-queued-campaigns",
schedule: "* * * * * *", // Runs every second
job: processQueuedCampaigns,
enabled: true,
}
name: "process-queued-campaigns",
schedule: "* * * * * *", // Runs every second
job: processQueuedCampaigns,
enabled: true,
};
const cronJobs: CronJob[] = [
sendMessagesJob,
dailyMaintenanceJob,
processQueuedCampaignsJob,
]
sendMessagesJob,
dailyMaintenanceJob,
processQueuedCampaignsJob,
];
export const initializeCronJobs = () => {
const scheduledJobs = cronJobs
.filter((job) => job.enabled)
.map((job) => {
const task = cron.schedule(job.schedule, job.job)
console.log(
`Cron job '${job.name}' scheduled with cron expression: ${job.schedule}`
)
return { name: job.name, task }
})
const scheduledJobs = cronJobs
.filter((job) => job.enabled)
.map((job) => {
const task = cron.schedule(job.schedule, job.job);
console.log(
`Cron job '${job.name}' scheduled with cron expression: ${job.schedule}`,
);
return { name: job.name, task };
});
console.log(`${scheduledJobs.length} cron jobs initialized`)
console.log(`${scheduledJobs.length} cron jobs initialized`);
return {
jobs: scheduledJobs,
stop: () => scheduledJobs.forEach(({ task }) => task.stop()),
}
}
return {
jobs: scheduledJobs,
stop: () => scheduledJobs.forEach(({ task }) => task.stop()),
};
};

View File

@@ -1,22 +1,22 @@
const runningJobs = new Map<string, boolean>()
const runningJobs = new Map<string, boolean>();
/**
* A wrapper for cron jobs
*/
export function cronJob(name: string, cronFn: () => Promise<void>) {
return async () => {
if (runningJobs.get(name)) {
return
}
return async () => {
if (runningJobs.get(name)) {
return;
}
runningJobs.set(name, true)
runningJobs.set(name, true);
try {
await cronFn()
} catch (error) {
console.error("Cron Error:", `[${name}]`, error)
} finally {
runningJobs.set(name, false)
}
}
try {
await cronFn();
} catch (error) {
console.error("Cron Error:", `[${name}]`, error);
} finally {
runningJobs.set(name, false);
}
};
}

View File

@@ -1,68 +1,68 @@
import { cronJob } from "./cron.utils"
import { prisma } from "../utils/prisma"
import dayjs from "dayjs"
import { cronJob } from "./cron.utils";
import { prisma } from "../utils/prisma";
import dayjs from "dayjs";
export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
const organizations = await prisma.organization.findMany({
include: {
GeneralSettings: true,
},
})
const organizations = await prisma.organization.findMany({
include: {
GeneralSettings: true,
},
});
let totalDeletedMessages = 0
let totalDeletedMessages = 0;
for (const org of organizations) {
const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30
const cleanupOlderThanDate = dayjs()
.subtract(cleanupIntervalDays, "days")
.toDate()
for (const org of organizations) {
const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30;
const cleanupOlderThanDate = dayjs()
.subtract(cleanupIntervalDays, "days")
.toDate();
try {
const messagesToClean = await prisma.message.findMany({
where: {
Campaign: {
organizationId: org.id,
},
status: {
in: ["SENT", "OPENED", "CLICKED", "FAILED"],
},
createdAt: {
lt: cleanupOlderThanDate,
},
},
select: {
id: true,
},
})
try {
const messagesToClean = await prisma.message.findMany({
where: {
Campaign: {
organizationId: org.id,
},
status: {
in: ["SENT", "OPENED", "CLICKED", "FAILED"],
},
createdAt: {
lt: cleanupOlderThanDate,
},
},
select: {
id: true,
},
});
await prisma.message.updateMany({
data: {
content: null,
},
where: {
id: {
in: messagesToClean.map((msg) => msg.id),
},
},
})
await prisma.message.updateMany({
data: {
content: null,
},
where: {
id: {
in: messagesToClean.map((msg) => msg.id),
},
},
});
if (messagesToClean.length > 0) {
console.log(
`Daily maintenance for org ${org.id}: Deleted ${messagesToClean.length} messages older than ${cleanupIntervalDays} days.`
)
totalDeletedMessages += messagesToClean.length
}
} catch (error) {
console.error(`Error deleting messages for org ${org.id}: ${error}`)
continue
}
}
if (messagesToClean.length > 0) {
console.log(
`Daily maintenance for org ${org.id}: Deleted ${messagesToClean.length} messages older than ${cleanupIntervalDays} days.`,
);
totalDeletedMessages += messagesToClean.length;
}
} catch (error) {
console.error(`Error deleting messages for org ${org.id}: ${error}`);
continue;
}
}
if (totalDeletedMessages > 0) {
console.log(
`Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.`
)
} else {
console.log("Daily maintenance job finished. No messages to delete.")
}
})
if (totalDeletedMessages > 0) {
console.log(
`Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.`,
);
} else {
console.log("Daily maintenance job finished. No messages to delete.");
}
});

View File

@@ -1,285 +1,285 @@
import { prisma } from "../utils/prisma"
import { LinkTracker } from "../lib/LinkTracker"
import { v4 as uuidV4 } from "uuid"
import { prisma } from "../utils/prisma";
import { LinkTracker } from "../lib/LinkTracker";
import { v4 as uuidV4 } from "uuid";
import {
replacePlaceholders,
PlaceholderDataKey,
} from "../utils/placeholder-parser"
import pMap from "p-map"
import { Subscriber, Prisma, SubscriberMetadata } from "../../prisma/client"
import { cronJob } from "./cron.utils"
replacePlaceholders,
PlaceholderDataKey,
} from "../utils/placeholder-parser";
import pMap from "p-map";
import { Subscriber, Prisma, SubscriberMetadata } from "../../prisma/client";
import { cronJob } from "./cron.utils";
// TODO: Make this a config
const BATCH_SIZE = 100
const BATCH_SIZE = 100;
async function getSubscribersForCampaign(
campaignId: string,
selectedListIds: string[]
campaignId: string,
selectedListIds: string[],
): Promise<Map<string, Subscriber & { Metadata: SubscriberMetadata[] }>> {
if (selectedListIds.length === 0) {
return new Map()
}
if (selectedListIds.length === 0) {
return new Map();
}
const subscribers = await prisma.subscriber.findMany({
where: {
Messages: { none: { campaignId } },
ListSubscribers: {
some: {
listId: { in: selectedListIds },
unsubscribedAt: null,
},
},
},
take: BATCH_SIZE,
include: {
Metadata: true,
},
})
const subscribers = await prisma.subscriber.findMany({
where: {
Messages: { none: { campaignId } },
ListSubscribers: {
some: {
listId: { in: selectedListIds },
unsubscribedAt: null,
},
},
},
take: BATCH_SIZE,
include: {
Metadata: true,
},
});
if (!subscribers.length) return new Map()
if (!subscribers.length) return new Map();
const subscribersMap = new Map<
string,
Subscriber & { Metadata: SubscriberMetadata[] }
>()
await pMap(subscribers, async (subscriber) => {
subscribersMap.set(subscriber.id, subscriber)
})
const subscribersMap = new Map<
string,
Subscriber & { Metadata: SubscriberMetadata[] }
>();
await pMap(subscribers, async (subscriber) => {
subscribersMap.set(subscriber.id, subscriber);
});
return subscribersMap
return subscribersMap;
}
const logged = {
noQueuedCampaigns: false,
missingCampaignData: false,
noSubscribers: false,
missingCampaignContent: false,
missingCampaignSubject: false,
errorProcessingCampaign: false,
}
noQueuedCampaigns: false,
missingCampaignData: false,
noSubscribers: false,
missingCampaignContent: false,
missingCampaignSubject: false,
errorProcessingCampaign: false,
};
const oneTimeLogger = (key: keyof typeof logged, ...messages: unknown[]) => {
if (!logged[key]) {
console.log(...messages)
logged[key] = true
}
}
if (!logged[key]) {
console.log(...messages);
logged[key] = true;
}
};
const turnOnLogger = (key: keyof typeof logged) => {
logged[key] = false
}
logged[key] = false;
};
export const processQueuedCampaigns = cronJob(
"process-queued-campaigns",
async () => {
const queuedCampaigns = await prisma.campaign.findMany({
where: {
status: "CREATING",
},
include: {
CampaignLists: {
select: { listId: true },
},
Organization: {
include: {
GeneralSettings: true,
SmtpSettings: true,
},
},
Template: true,
},
})
"process-queued-campaigns",
async () => {
const queuedCampaigns = await prisma.campaign.findMany({
where: {
status: "CREATING",
},
include: {
CampaignLists: {
select: { listId: true },
},
Organization: {
include: {
GeneralSettings: true,
SmtpSettings: true,
},
},
Template: true,
},
});
if (queuedCampaigns.length === 0) {
oneTimeLogger(
"noQueuedCampaigns",
"Cron job: No queued campaigns to process."
)
return
}
if (queuedCampaigns.length === 0) {
oneTimeLogger(
"noQueuedCampaigns",
"Cron job: No queued campaigns to process.",
);
return;
}
turnOnLogger("noQueuedCampaigns")
turnOnLogger("noQueuedCampaigns");
for (const campaign of queuedCampaigns) {
try {
if (
!campaign ||
!campaign.content ||
!campaign.subject ||
!campaign.Organization ||
!campaign.Organization.GeneralSettings?.baseURL
) {
oneTimeLogger(
"missingCampaignData",
`Cron job: Campaign ${campaign.id} is missing required data (content, subject, organization, or baseURL). Skipping.`
)
// Optionally, update status to FAILED or similar
// await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'FAILED', statusReason: 'Missing critical data for processing' } });
continue
}
for (const campaign of queuedCampaigns) {
try {
if (
!campaign ||
!campaign.content ||
!campaign.subject ||
!campaign.Organization ||
!campaign.Organization.GeneralSettings?.baseURL
) {
oneTimeLogger(
"missingCampaignData",
`Cron job: Campaign ${campaign.id} is missing required data (content, subject, organization, or baseURL). Skipping.`,
);
// Optionally, update status to FAILED or similar
// await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'FAILED', statusReason: 'Missing critical data for processing' } });
continue;
}
turnOnLogger("missingCampaignData")
turnOnLogger("missingCampaignData");
const generalSettings = campaign.Organization.GeneralSettings
const generalSettings = campaign.Organization.GeneralSettings;
const selectedListIds = campaign.CampaignLists.map((cl) => cl.listId)
const selectedListIds = campaign.CampaignLists.map((cl) => cl.listId);
const allSubscribersMap = await getSubscribersForCampaign(
campaign.id,
selectedListIds
)
if (allSubscribersMap.size === 0) {
oneTimeLogger(
"noSubscribers",
`Cron job: Campaign ${campaign.id} has no subscribers. Skipping.`
)
continue
}
const allSubscribersMap = await getSubscribersForCampaign(
campaign.id,
selectedListIds,
);
if (allSubscribersMap.size === 0) {
oneTimeLogger(
"noSubscribers",
`Cron job: Campaign ${campaign.id} has no subscribers. Skipping.`,
);
continue;
}
turnOnLogger("noSubscribers")
turnOnLogger("noSubscribers");
const messageSubscriberIds = (
await prisma.message.findMany({
where: { campaignId: campaign.id },
select: { subscriberId: true },
})
).map((m) => m.subscriberId)
const subscribersWithMessage = new Set(messageSubscriberIds)
const messageSubscriberIds = (
await prisma.message.findMany({
where: { campaignId: campaign.id },
select: { subscriberId: true },
})
).map((m) => m.subscriberId);
const subscribersWithMessage = new Set(messageSubscriberIds);
const subscribersToProcess = Array.from(
allSubscribersMap.values()
).filter((sub) => !subscribersWithMessage.has(sub.id))
const subscribersToProcess = Array.from(
allSubscribersMap.values(),
).filter((sub) => !subscribersWithMessage.has(sub.id));
if (subscribersToProcess.length === 0) {
continue
}
if (subscribersToProcess.length === 0) {
continue;
}
await prisma.$transaction(
async (tx) => {
const linkTracker = new LinkTracker(tx)
const messagesToCreate: Prisma.MessageCreateManyInput[] = []
await prisma.$transaction(
async (tx) => {
const linkTracker = new LinkTracker(tx);
const messagesToCreate: Prisma.MessageCreateManyInput[] = [];
for (const subscriber of subscribersToProcess) {
const messageId = uuidV4()
if (!campaign.content) {
oneTimeLogger(
"missingCampaignContent",
`Cron job: Campaign ${campaign.id} has no content. Skipping.`
)
continue
}
for (const subscriber of subscribersToProcess) {
const messageId = uuidV4();
if (!campaign.content) {
oneTimeLogger(
"missingCampaignContent",
`Cron job: Campaign ${campaign.id} has no content. Skipping.`,
);
continue;
}
turnOnLogger("missingCampaignContent")
turnOnLogger("missingCampaignContent");
let emailContent = campaign.Template
? campaign.Template.content.replace(
/{{content}}/g,
campaign.content
)
: campaign.content
let emailContent = campaign.Template
? campaign.Template.content.replace(
/{{content}}/g,
campaign.content,
)
: campaign.content;
if (!campaign.subject) {
oneTimeLogger(
"missingCampaignSubject",
`Cron job: Campaign ${campaign.id} has no subject. Skipping.`
)
continue
}
if (!campaign.subject) {
oneTimeLogger(
"missingCampaignSubject",
`Cron job: Campaign ${campaign.id} has no subject. Skipping.`,
);
continue;
}
turnOnLogger("missingCampaignSubject")
turnOnLogger("missingCampaignSubject");
const placeholderData: Partial<
Record<PlaceholderDataKey, string>
> = {
"subscriber.email": subscriber.email,
"campaign.name": campaign.title,
"campaign.subject": campaign.subject,
"organization.name": campaign.Organization.name,
unsubscribe_link: `${generalSettings.baseURL}/unsubscribe?sid=${subscriber.id}&cid=${campaign.id}&mid=${messageId}`,
current_date: new Date().toLocaleDateString("en-CA"),
}
const placeholderData: Partial<
Record<PlaceholderDataKey, string>
> = {
"subscriber.email": subscriber.email,
"campaign.name": campaign.title,
"campaign.subject": campaign.subject,
"organization.name": campaign.Organization.name,
unsubscribe_link: `${generalSettings.baseURL}/unsubscribe?sid=${subscriber.id}&cid=${campaign.id}&mid=${messageId}`,
current_date: new Date().toLocaleDateString("en-CA"),
};
if (campaign.openTracking) {
emailContent += `<img src="${generalSettings.baseURL}/img/${messageId}/img.png" alt="" width="1" height="1" style="display:none" />`
}
if (campaign.openTracking) {
emailContent += `<img src="${generalSettings.baseURL}/img/${messageId}/img.png" alt="" width="1" height="1" style="display:none" />`;
}
if (subscriber.name) {
placeholderData["subscriber.name"] = subscriber.name
}
if (subscriber.Metadata) {
for (const meta of subscriber.Metadata) {
placeholderData[`subscriber.metadata.${meta.key}`] =
meta.value
}
}
if (subscriber.name) {
placeholderData["subscriber.name"] = subscriber.name;
}
if (subscriber.Metadata) {
for (const meta of subscriber.Metadata) {
placeholderData[`subscriber.metadata.${meta.key}`] =
meta.value;
}
}
emailContent = replacePlaceholders(emailContent, placeholderData)
emailContent = replacePlaceholders(emailContent, placeholderData);
if (!generalSettings.baseURL) {
console.error(
`Cron job: Campaign ${campaign.id} has no baseURL. Skipping.`
)
continue
}
if (!generalSettings.baseURL) {
console.error(
`Cron job: Campaign ${campaign.id} has no baseURL. Skipping.`,
);
continue;
}
const { content: finalContent } =
await linkTracker.replaceMessageContentWithTrackedLinks(
emailContent,
campaign.id,
generalSettings.baseURL
)
const { content: finalContent } =
await linkTracker.replaceMessageContentWithTrackedLinks(
emailContent,
campaign.id,
generalSettings.baseURL,
);
messagesToCreate.push({
id: messageId,
campaignId: campaign.id,
subscriberId: subscriber.id,
content: finalContent,
status: "QUEUED",
})
}
messagesToCreate.push({
id: messageId,
campaignId: campaign.id,
subscriberId: subscriber.id,
content: finalContent,
status: "QUEUED",
});
}
if (messagesToCreate.length > 0) {
await tx.message.createMany({
data: messagesToCreate,
})
if (messagesToCreate.length > 0) {
await tx.message.createMany({
data: messagesToCreate,
});
const subscribersLeft = await tx.subscriber.count({
where: {
Messages: { none: { campaignId: campaign.id } },
ListSubscribers: {
some: {
listId: { in: selectedListIds },
unsubscribedAt: null,
},
},
},
})
const subscribersLeft = await tx.subscriber.count({
where: {
Messages: { none: { campaignId: campaign.id } },
ListSubscribers: {
some: {
listId: { in: selectedListIds },
unsubscribedAt: null,
},
},
},
});
if (subscribersLeft === 0) {
await tx.campaign.update({
where: { id: campaign.id },
data: { status: "SENDING" },
})
}
if (subscribersLeft === 0) {
await tx.campaign.update({
where: { id: campaign.id },
data: { status: "SENDING" },
});
}
console.log(
`Cron job: Created ${messagesToCreate.length} messages for campaign ${campaign.id}.`
)
}
},
{ timeout: 60_000 }
) // End transaction
console.log(
`Cron job: Created ${messagesToCreate.length} messages for campaign ${campaign.id}.`,
);
}
},
{ timeout: 60_000 },
); // End transaction
turnOnLogger("errorProcessingCampaign")
} catch (error) {
oneTimeLogger(
"errorProcessingCampaign",
`Cron job: Error processing campaign ${campaign.id}:`,
error
)
// Optionally, mark campaign as FAILED
// await prisma.campaign.update({ where: { id: basicCampaignInfo.id }, data: { status: 'FAILED', statusReason: error.message }});
}
}
}
)
turnOnLogger("errorProcessingCampaign");
} catch (error) {
oneTimeLogger(
"errorProcessingCampaign",
`Cron job: Error processing campaign ${campaign.id}:`,
error,
);
// Optionally, mark campaign as FAILED
// await prisma.campaign.update({ where: { id: basicCampaignInfo.id }, data: { status: 'FAILED', statusReason: error.message }});
}
}
},
);

View File

@@ -1,190 +1,190 @@
import pMap from "p-map"
import { Mailer } from "../lib/Mailer"
import { logger } from "../utils/logger"
import { prisma } from "../utils/prisma"
import pMap from "p-map";
import { Mailer } from "../lib/Mailer";
import { logger } from "../utils/logger";
import { prisma } from "../utils/prisma";
import { cronJob } from "./cron.utils"
import { subSeconds } from "date-fns"
import { cronJob } from "./cron.utils";
import { subSeconds } from "date-fns";
export const sendMessagesCron = cronJob("sendMessages", async () => {
const organizations = await prisma.organization.findMany()
const organizations = await prisma.organization.findMany();
for (const organization of organizations) {
const [smtpSettings, emailSettings, generalSettings] = await Promise.all([
prisma.smtpSettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.emailDeliverySettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.generalSettings.findFirst({
where: { organizationId: organization.id },
}),
])
for (const organization of organizations) {
const [smtpSettings, emailSettings, generalSettings] = await Promise.all([
prisma.smtpSettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.emailDeliverySettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.generalSettings.findFirst({
where: { organizationId: organization.id },
}),
]);
if (!smtpSettings || !emailSettings) {
logger.warn(
`Required settings not found for org ${organization.id}, skipping`
)
continue
}
if (!smtpSettings || !emailSettings) {
logger.warn(
`Required settings not found for org ${organization.id}, skipping`,
);
continue;
}
const windowStart = subSeconds(new Date(), emailSettings.rateWindow)
const sentInWindow = await prisma.message.count({
where: {
status: {
in: ["PENDING", "SENT", "OPENED", "CLICKED"],
},
sentAt: {
gte: windowStart,
},
Campaign: {
organizationId: organization.id,
},
},
})
const windowStart = subSeconds(new Date(), emailSettings.rateWindow);
const sentInWindow = await prisma.message.count({
where: {
status: {
in: ["PENDING", "SENT", "OPENED", "CLICKED"],
},
sentAt: {
gte: windowStart,
},
Campaign: {
organizationId: organization.id,
},
},
});
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow)
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow);
if (availableSlots === 0) {
continue
}
if (availableSlots === 0) {
continue;
}
// Message status is now independent of campaign status.
// This allows retrying individual messages even for completed campaigns.
// We only filter by QUEUED and RETRYING message statuses.
const messages = await prisma.message.findMany({
where: {
Campaign: {
organizationId: organization.id,
},
OR: [
{ status: "QUEUED" },
{
status: "RETRYING",
lastTriedAt: {
lte: subSeconds(new Date(), emailSettings.retryDelay),
},
},
],
},
include: {
Subscriber: {
select: {
email: true,
},
},
Campaign: {
select: {
subject: true,
},
},
},
take: availableSlots,
})
// Message status is now independent of campaign status.
// This allows retrying individual messages even for completed campaigns.
// We only filter by QUEUED and RETRYING message statuses.
const messages = await prisma.message.findMany({
where: {
Campaign: {
organizationId: organization.id,
},
OR: [
{ status: "QUEUED" },
{
status: "RETRYING",
lastTriedAt: {
lte: subSeconds(new Date(), emailSettings.retryDelay),
},
},
],
},
include: {
Subscriber: {
select: {
email: true,
},
},
Campaign: {
select: {
subject: true,
},
},
},
take: availableSlots,
});
const noMoreRetryingMessages = await prisma.message.count({
where: {
status: "RETRYING",
Campaign: {
organizationId: organization.id,
},
},
})
const noMoreRetryingMessages = await prisma.message.count({
where: {
status: "RETRYING",
Campaign: {
organizationId: organization.id,
},
},
});
if (!messages.length && noMoreRetryingMessages === 0) {
await prisma.campaign.updateMany({
where: {
status: "SENDING",
organizationId: organization.id,
Messages: {
every: {
status: {
in: ["SENT", "FAILED", "OPENED", "CLICKED", "CANCELLED"],
},
},
},
},
data: {
status: "COMPLETED",
completedAt: new Date(),
},
})
continue
}
if (!messages.length && noMoreRetryingMessages === 0) {
await prisma.campaign.updateMany({
where: {
status: "SENDING",
organizationId: organization.id,
Messages: {
every: {
status: {
in: ["SENT", "FAILED", "OPENED", "CLICKED", "CANCELLED"],
},
},
},
},
data: {
status: "COMPLETED",
completedAt: new Date(),
},
});
continue;
}
logger.info(`Found ${messages.length} messages to send`)
logger.info(`Found ${messages.length} messages to send`);
const mailer = new Mailer({
...smtpSettings,
timeout: emailSettings.connectionTimeout,
})
const mailer = new Mailer({
...smtpSettings,
timeout: emailSettings.connectionTimeout,
});
const fromName =
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? ""
const fromEmail =
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? ""
const fromName =
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? "";
const fromEmail =
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? "";
if (!fromName || !fromEmail) {
logger.warn("No from name or email found, message will not be sent")
continue
}
if (!fromName || !fromEmail) {
logger.warn("No from name or email found, message will not be sent");
continue;
}
await pMap(
messages,
async (message) => {
if (!message.Campaign.subject) {
logger.warn("No subject found for campaign")
return
}
await pMap(
messages,
async (message) => {
if (!message.Campaign.subject) {
logger.warn("No subject found for campaign");
return;
}
await prisma.message.update({
where: { id: message.id },
data: { status: "PENDING" },
})
await prisma.message.update({
where: { id: message.id },
data: { status: "PENDING" },
});
try {
const result = await mailer.sendEmail({
to: message.Subscriber.email,
subject: message.Campaign.subject,
html: message.content,
from: `${fromName} <${fromEmail}>`,
})
try {
const result = await mailer.sendEmail({
to: message.Subscriber.email,
subject: message.Campaign.subject,
html: message.content,
from: `${fromName} <${fromEmail}>`,
});
await prisma.message.update({
where: { id: message.id },
data: {
messageId: result.messageId,
status: result.success
? "SENT"
: message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
sentAt: result.success ? new Date() : undefined,
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
})
} catch (error) {
await prisma.message.update({
where: { id: message.id },
data: {
status:
message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
error: String(error),
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
})
}
},
{ concurrency: emailSettings.concurrency }
)
}
})
await prisma.message.update({
where: { id: message.id },
data: {
messageId: result.messageId,
status: result.success
? "SENT"
: message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
sentAt: result.success ? new Date() : undefined,
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
});
} catch (error) {
await prisma.message.update({
where: { id: message.id },
data: {
status:
message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
error: String(error),
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
});
}
},
{ concurrency: emailSettings.concurrency },
);
}
});

View File

@@ -1,145 +1,145 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { MessageStatus } from "../../prisma/client"
import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql"
import pMap from "p-map"
import { subMonths } from "date-fns"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { MessageStatus } from "../../prisma/client";
import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql";
import pMap from "p-map";
import { subMonths } from "date-fns";
export const getDashboardStats = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const from = subMonths(new Date(), 6)
const to = new Date()
const from = subMonths(new Date(), 6);
const to = new Date();
const dateFilter = {
...(from && to
? {
createdAt: {
gte: from,
lte: to,
},
}
: {}),
}
const dateFilter = {
...(from && to
? {
createdAt: {
gte: from,
lte: to,
},
}
: {}),
};
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
await Promise.all([
// Message delivery stats
prisma.message.groupBy({
by: ["status"],
where: {
Campaign: {
organizationId: input.organizationId,
},
...dateFilter,
},
_count: true,
}),
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
await Promise.all([
// Message delivery stats
prisma.message.groupBy({
by: ["status"],
where: {
Campaign: {
organizationId: input.organizationId,
},
...dateFilter,
},
_count: true,
}),
// Recent campaigns with stats
prisma.campaign.findMany({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
...dateFilter,
},
include: {
_count: {
select: {
Messages: true,
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: 5,
}),
// Recent campaigns with stats
prisma.campaign.findMany({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
...dateFilter,
},
include: {
_count: {
select: {
Messages: true,
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: 5,
}),
// Subscriber growth over time
prisma.$queryRawTyped(
subscriberGrowthQuery(input.organizationId, from, to)
),
// Subscriber growth over time
prisma.$queryRawTyped(
subscriberGrowthQuery(input.organizationId, from, to),
),
prisma.$queryRawTyped(countDbSize(input.organizationId)),
])
prisma.$queryRawTyped(countDbSize(input.organizationId)),
]);
// Process message stats
const messageStatsByStatus = messageStats.reduce(
(acc, stat) => {
acc[stat.status as MessageStatus] = stat._count
return acc
},
{} as Record<MessageStatus, number>
)
// Process message stats
const messageStatsByStatus = messageStats.reduce(
(acc, stat) => {
acc[stat.status as MessageStatus] = stat._count;
return acc;
},
{} as Record<MessageStatus, number>,
);
// Process recent campaigns
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
const [deliveredCount, totalCount] = await Promise.all([
prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
prisma.message.count({
where: { campaignId: campaign.id },
}),
])
// Process recent campaigns
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
const [deliveredCount, totalCount] = await Promise.all([
prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
prisma.message.count({
where: { campaignId: campaign.id },
}),
]);
return {
id: campaign.id,
title: campaign.title,
status: campaign.status,
completedAt: campaign.completedAt,
deliveryRate: totalCount > 0 ? (deliveredCount / totalCount) * 100 : 0,
totalMessages: totalCount,
sentMessages: deliveredCount,
createdAt: campaign.createdAt,
}
})
return {
id: campaign.id,
title: campaign.title,
status: campaign.status,
completedAt: campaign.completedAt,
deliveryRate: totalCount > 0 ? (deliveredCount / totalCount) * 100 : 0,
totalMessages: totalCount,
sentMessages: deliveredCount,
createdAt: campaign.createdAt,
};
});
const subscriberGrowthCumulative: { date: Date; count: number }[] = []
const subscriberGrowthCumulative: { date: Date; count: number }[] = [];
for (let i = 0; i < subscriberGrowth.length; i++) {
const point = subscriberGrowth[i]
for (let i = 0; i < subscriberGrowth.length; i++) {
const point = subscriberGrowth[i];
if (!point?.date) {
continue
}
if (!point?.date) {
continue;
}
const prev = subscriberGrowthCumulative[i - 1]?.count || 0
const prev = subscriberGrowthCumulative[i - 1]?.count || 0;
subscriberGrowthCumulative.push({
date: point.date,
count: Number(point.count) + Number(prev),
})
}
subscriberGrowthCumulative.push({
date: point.date,
count: Number(point.count) + Number(prev),
});
}
return {
messageStats: messageStatsByStatus,
recentCampaigns: processedCampaigns,
subscriberGrowth: subscriberGrowthCumulative,
dbSize,
}
})
return {
messageStats: messageStatsByStatus,
recentCampaigns: processedCampaigns,
subscriberGrowth: subscriberGrowthCumulative,
dbSize,
};
});

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc"
import { getDashboardStats } from "./query"
import { router } from "../trpc";
import { getDashboardStats } from "./query";
export const dashboardRouter = router({
getStats: getDashboardStats,
})
getStats: getDashboardStats,
});

View File

@@ -1,44 +1,44 @@
export type * from "./app"
export type * from "../prisma/client"
export type * from "./types"
export type * from "./app";
export type * from "../prisma/client";
export type * from "./types";
import { app } from "./app"
import { initializeCronJobs } from "./cron/cron"
import { prisma } from "./utils/prisma"
import { app } from "./app";
import { initializeCronJobs } from "./cron/cron";
import { prisma } from "./utils/prisma";
const cronController = initializeCronJobs()
const cronController = initializeCronJobs();
const PORT = process.env.PORT || 5000
const PORT = process.env.PORT || 5000;
prisma.$connect().then(async () => {
console.log("Connected to database")
console.log("Connected to database");
// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
await prisma.message.updateMany({
where: {
Campaign: {
status: "CANCELLED",
},
status: {
in: ["QUEUED", "PENDING", "RETRYING"],
},
},
data: {
status: "CANCELLED",
},
})
// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
await prisma.message.updateMany({
where: {
Campaign: {
status: "CANCELLED",
},
status: {
in: ["QUEUED", "PENDING", "RETRYING"],
},
},
data: {
status: "CANCELLED",
},
});
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})
})
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
});
// Handle graceful shutdown
const shutdown = () => {
console.log("Shutting down cron jobs...")
cronController.stop()
process.exit(0)
}
console.log("Shutting down cron jobs...");
cronController.stop();
process.exit(0);
};
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

View File

@@ -1,102 +1,102 @@
import { prisma } from "../utils/prisma"
import { prisma } from "../utils/prisma";
type TransactionClient = Parameters<
Parameters<typeof prisma.$transaction>[0]
>[0]
Parameters<typeof prisma.$transaction>[0]
>[0];
export class LinkTracker {
private readonly trackSuffix = "@TRACK"
private readonly tx: TransactionClient
private readonly trackSuffix = "@TRACK";
private readonly tx: TransactionClient;
constructor(tx: TransactionClient) {
this.tx = tx
}
constructor(tx: TransactionClient) {
this.tx = tx;
}
private async getOrCreateTrackLink(url: string, campaignId: string) {
const originalUrl = url.replace(this.trackSuffix, "")
private async getOrCreateTrackLink(url: string, campaignId: string) {
const originalUrl = url.replace(this.trackSuffix, "");
try {
const trackedLink = await this.tx.trackedLink.upsert({
where: {
url_campaignId: {
url: originalUrl,
campaignId,
},
},
create: {
url: originalUrl,
campaignId,
},
update: {},
})
try {
const trackedLink = await this.tx.trackedLink.upsert({
where: {
url_campaignId: {
url: originalUrl,
campaignId,
},
},
create: {
url: originalUrl,
campaignId,
},
update: {},
});
return trackedLink
} catch (error) {
// In case of race condition, try to fetch the existing record
return await this.tx.trackedLink.findFirstOrThrow({
where: {
url: originalUrl,
campaignId,
},
})
}
}
return trackedLink;
} catch (error) {
// In case of race condition, try to fetch the existing record
return await this.tx.trackedLink.findFirstOrThrow({
where: {
url: originalUrl,
campaignId,
},
});
}
}
private findTrackingLinks(content: string) {
const regex = /https?:\/\/[^\s<>"']+@TRACK/g
const matches = content.match(regex)
private findTrackingLinks(content: string) {
const regex = /https?:\/\/[^\s<>"']+@TRACK/g;
const matches = content.match(regex);
if (!matches) {
return []
}
if (!matches) {
return [];
}
return matches
}
return matches;
}
async findTrackingLinksAndCreate({
content,
campaignId,
}: {
content: string
campaignId: string
}) {
const links = this.findTrackingLinks(content)
async findTrackingLinksAndCreate({
content,
campaignId,
}: {
content: string;
campaignId: string;
}) {
const links = this.findTrackingLinks(content);
const trackingLinks = await Promise.all(
links.map((link) => this.getOrCreateTrackLink(link, campaignId))
)
const trackingLinks = await Promise.all(
links.map((link) => this.getOrCreateTrackLink(link, campaignId)),
);
return trackingLinks
}
return trackingLinks;
}
async replaceMessageContentWithTrackedLinks(
content: string,
campaignId: string,
baseURL: string
) {
const links = this.findTrackingLinks(content)
let updatedContent = content
async replaceMessageContentWithTrackedLinks(
content: string,
campaignId: string,
baseURL: string,
) {
const links = this.findTrackingLinks(content);
let updatedContent = content;
const trackedLinkResults = await Promise.all(
links.map(async (link) => {
const trackedLink = await this.getOrCreateTrackLink(link, campaignId)
const trackingUrl = `${baseURL}/r/${trackedLink.id}`
const trackedLinkResults = await Promise.all(
links.map(async (link) => {
const trackedLink = await this.getOrCreateTrackLink(link, campaignId);
const trackingUrl = `${baseURL}/r/${trackedLink.id}`;
return {
originalLink: link,
trackedLinkId: trackedLink.id,
trackingUrl,
}
})
)
return {
originalLink: link,
trackedLinkId: trackedLink.id,
trackingUrl,
};
}),
);
trackedLinkResults.forEach(({ originalLink, trackingUrl }) => {
updatedContent = updatedContent.replace(originalLink, trackingUrl)
})
trackedLinkResults.forEach(({ originalLink, trackingUrl }) => {
updatedContent = updatedContent.replace(originalLink, trackingUrl);
});
return {
content: updatedContent,
trackedIds: trackedLinkResults.map(({ trackedLinkId }) => trackedLinkId),
}
}
return {
content: updatedContent,
trackedIds: trackedLinkResults.map(({ trackedLinkId }) => trackedLinkId),
};
}
}

View File

@@ -1,103 +1,103 @@
import SMTPTransport from "nodemailer/lib/smtp-transport"
import { SmtpSettings } from "../../prisma/client"
import nodemailer from "nodemailer"
import SMTPTransport from "nodemailer/lib/smtp-transport";
import { SmtpSettings } from "../../prisma/client";
import nodemailer from "nodemailer";
type SendMailOptions = {
from: string
to: string
subject: string
html?: string | null
text?: string | null
}
from: string;
to: string;
subject: string;
html?: string | null;
text?: string | null;
};
interface Envelope {
from: string
to: string[]
from: string;
to: string[];
}
interface SMTPResponse {
accepted: string[]
rejected: string[]
ehlo: string[]
envelopeTime: number
messageTime: number
messageSize: number
response: string
envelope: Envelope
messageId: string
accepted: string[];
rejected: string[];
ehlo: string[];
envelopeTime: number;
messageTime: number;
messageSize: number;
response: string;
envelope: Envelope;
messageId: string;
}
interface SendEmailResponse {
success: boolean
from: string
messageId?: string
success: boolean;
from: string;
messageId?: string;
}
type TransportOptions = SMTPTransport | SMTPTransport.Options | string
type TransportOptions = SMTPTransport | SMTPTransport.Options | string;
export class Mailer {
private transporter: nodemailer.Transporter
private transporter: nodemailer.Transporter;
constructor(smtpSettings: SmtpSettings) {
let transportOptions: TransportOptions = {
host: smtpSettings.host,
port: smtpSettings.port,
connectionTimeout: smtpSettings.timeout,
auth: {
user: smtpSettings.username,
pass: smtpSettings.password,
},
}
constructor(smtpSettings: SmtpSettings) {
let transportOptions: TransportOptions = {
host: smtpSettings.host,
port: smtpSettings.port,
connectionTimeout: smtpSettings.timeout,
auth: {
user: smtpSettings.username,
pass: smtpSettings.password,
},
};
if (smtpSettings.encryption === "STARTTLS") {
transportOptions = {
...transportOptions,
port: smtpSettings.port || 587, // Default STARTTLS port
secure: false, // Use STARTTLS
requireTLS: true, // Require STARTTLS upgrade
}
} else if (smtpSettings.encryption === "SSL_TLS") {
transportOptions = {
...transportOptions,
port: smtpSettings.port || 465, // Default SSL/TLS port
secure: true, // Use direct TLS connection
}
} else {
// NONE encryption
transportOptions = {
...transportOptions,
port: smtpSettings.port || 25, // Default non-encrypted port
secure: false,
requireTLS: false, // Explicitly disable TLS requirement
ignoreTLS: true, // Optionally ignore TLS advertised by server if needed
}
}
if (smtpSettings.encryption === "STARTTLS") {
transportOptions = {
...transportOptions,
port: smtpSettings.port || 587, // Default STARTTLS port
secure: false, // Use STARTTLS
requireTLS: true, // Require STARTTLS upgrade
};
} else if (smtpSettings.encryption === "SSL_TLS") {
transportOptions = {
...transportOptions,
port: smtpSettings.port || 465, // Default SSL/TLS port
secure: true, // Use direct TLS connection
};
} else {
// NONE encryption
transportOptions = {
...transportOptions,
port: smtpSettings.port || 25, // Default non-encrypted port
secure: false,
requireTLS: false, // Explicitly disable TLS requirement
ignoreTLS: true, // Optionally ignore TLS advertised by server if needed
};
}
this.transporter = nodemailer.createTransport(transportOptions)
}
this.transporter = nodemailer.createTransport(transportOptions);
}
async sendEmail(options: SendMailOptions): Promise<SendEmailResponse> {
const result: SMTPResponse = await this.transporter.sendMail({
to: [options.to],
subject: options.subject,
from: options.from,
// TODO: Handle plain text
text: options.text || undefined,
html: options.html || undefined,
})
async sendEmail(options: SendMailOptions): Promise<SendEmailResponse> {
const result: SMTPResponse = await this.transporter.sendMail({
to: [options.to],
subject: options.subject,
from: options.from,
// TODO: Handle plain text
text: options.text || undefined,
html: options.html || undefined,
});
let response: SendEmailResponse = {
success: false,
messageId: result.messageId,
from: options.from,
}
let response: SendEmailResponse = {
success: false,
messageId: result.messageId,
from: options.from,
};
if (result.accepted.length > 0) {
response.success = true
} else if (result.rejected.length > 0) {
response.success = false
}
if (result.accepted.length > 0) {
response.success = true;
} else if (result.rejected.length > 0) {
response.success = false;
}
return response
}
return response;
}
}

View File

@@ -1,151 +1,151 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
const createListSchema = z.object({
name: z.string().min(1, "List name is required"),
description: z.string().optional(),
organizationId: z.string(),
})
name: z.string().min(1, "List name is required"),
description: z.string().optional(),
organizationId: z.string(),
});
export const createList = authProcedure
.input(createListSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(createListSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const list = await prisma.list.create({
data: {
name: input.name,
description: input.description,
organizationId: input.organizationId,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
},
})
const list = await prisma.list.create({
data: {
name: input.name,
description: input.description,
organizationId: input.organizationId,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
},
});
return {
list,
}
})
return {
list,
};
});
const updateListSchema = z.object({
id: z.string(),
name: z.string().min(1, "List name is required"),
description: z.string().optional(),
})
id: z.string(),
name: z.string().min(1, "List name is required"),
description: z.string().optional(),
});
export const updateList = authProcedure
.input(updateListSchema)
.mutation(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
},
})
.input(updateListSchema)
.mutation(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
},
});
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
})
}
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
});
}
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
})
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
});
}
const updatedList = await prisma.list.update({
where: {
id: input.id,
},
data: {
name: input.name,
description: input.description,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
})
const updatedList = await prisma.list.update({
where: {
id: input.id,
},
data: {
name: input.name,
description: input.description,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
});
return {
list: updatedList,
}
})
return {
list: updatedList,
};
});
export const deleteList = authProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
},
})
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
},
});
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
})
}
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
});
}
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
})
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
});
}
await prisma.list.delete({
where: {
id: input.id,
},
})
await prisma.list.delete({
where: {
id: input.id,
},
});
return { success: true }
})
return { success: true };
});

View File

@@ -1,123 +1,123 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
export const getLists = authProcedure
.input(
z
.object({
organizationId: z.string(),
})
.merge(paginationSchema)
)
.query(async ({ ctx, input }) => {
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z
.object({
organizationId: z.string(),
})
.merge(paginationSchema),
)
.query(async ({ ctx, input }) => {
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const where: Prisma.ListWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
}
const where: Prisma.ListWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const [total, lists] = await Promise.all([
prisma.list.count({ where }),
prisma.list.findMany({
where,
include: {
_count: {
select: {
ListSubscribers: {
where: {
unsubscribedAt: null,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
])
const [total, lists] = await Promise.all([
prisma.list.count({ where }),
prisma.list.findMany({
where,
include: {
_count: {
select: {
ListSubscribers: {
where: {
unsubscribedAt: null,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
lists,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
lists,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getList = authProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
ListSubscribers: {
include: {
Subscriber: true,
},
},
},
})
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
ListSubscribers: {
include: {
Subscriber: true,
},
},
},
});
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
})
}
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
});
}
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
})
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
});
}
return list
})
return list;
});

View File

@@ -1,11 +1,11 @@
import { router } from "../trpc"
import { createList, updateList, deleteList } from "./mutation"
import { getList, getLists } from "./query"
import { router } from "../trpc";
import { createList, updateList, deleteList } from "./mutation";
import { getList, getLists } from "./query";
export const listRouter = router({
create: createList,
update: updateList,
delete: deleteList,
get: getList,
list: getLists,
})
create: createList,
update: updateList,
delete: deleteList,
get: getList,
list: getLists,
});

View File

@@ -1,59 +1,59 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { MessageStatus } from "../../prisma/client"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { MessageStatus } from "../../prisma/client";
export const resendMessage = authProcedure
.input(
z.object({
messageId: z.string(),
organizationId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
messageId: z.string(),
organizationId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization.",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization.",
});
}
const message = await prisma.message.findFirst({
where: {
id: input.messageId,
Campaign: {
organizationId: input.organizationId,
},
},
})
const message = await prisma.message.findFirst({
where: {
id: input.messageId,
Campaign: {
organizationId: input.organizationId,
},
},
});
if (!message) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found or you don't have access.",
})
}
if (!message) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found or you don't have access.",
});
}
const updatedMessage = await prisma.message.update({
where: {
id: input.messageId,
},
data: {
status: MessageStatus.QUEUED,
tries: 0,
lastTriedAt: null,
error: null,
messageId: null,
},
})
const updatedMessage = await prisma.message.update({
where: {
id: input.messageId,
},
data: {
status: MessageStatus.QUEUED,
tries: 0,
lastTriedAt: null,
error: null,
messageId: null,
},
});
return updatedMessage
})
return updatedMessage;
});

View File

@@ -1,159 +1,159 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
const messageStatusEnum = z.enum([
"QUEUED",
"PENDING",
"SENT",
"OPENED",
"CLICKED",
"FAILED",
"RETRYING",
])
"QUEUED",
"PENDING",
"SENT",
"OPENED",
"CLICKED",
"FAILED",
"RETRYING",
]);
export const listMessages = authProcedure
.input(
z
.object({
organizationId: z.string(),
campaignId: z.string().optional(),
subscriberId: z.string().optional(),
status: messageStatusEnum.optional(),
})
.merge(paginationSchema)
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z
.object({
organizationId: z.string(),
campaignId: z.string().optional(),
subscriberId: z.string().optional(),
status: messageStatusEnum.optional(),
})
.merge(paginationSchema),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const where: Prisma.MessageWhereInput = {
Campaign: {
organizationId: input.organizationId,
},
...(input.campaignId ? { campaignId: input.campaignId } : {}),
...(input.subscriberId ? { subscriberId: input.subscriberId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.search
? {
OR: [
{
Subscriber: {
name: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Subscriber: {
email: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Campaign: {
title: {
contains: input.search,
mode: "insensitive",
},
},
},
],
}
: {}),
}
const where: Prisma.MessageWhereInput = {
Campaign: {
organizationId: input.organizationId,
},
...(input.campaignId ? { campaignId: input.campaignId } : {}),
...(input.subscriberId ? { subscriberId: input.subscriberId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.search
? {
OR: [
{
Subscriber: {
name: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Subscriber: {
email: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Campaign: {
title: {
contains: input.search,
mode: "insensitive",
},
},
},
],
}
: {}),
};
const [total, messages] = await Promise.all([
prisma.message.count({ where }),
prisma.message.findMany({
where,
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Campaign: {
select: {
id: true,
title: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
}),
])
const [total, messages] = await Promise.all([
prisma.message.count({ where }),
prisma.message.findMany({
where,
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Campaign: {
select: {
id: true,
title: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
}),
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
messages,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
messages,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getMessage = authProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ input }) => {
const message = await prisma.message.findUnique({
where: {
id: input.id,
},
include: {
Campaign: {
select: {
id: true,
title: true,
content: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
})
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ input }) => {
const message = await prisma.message.findUnique({
where: {
id: input.id,
},
include: {
Campaign: {
select: {
id: true,
title: true,
content: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
if (!message) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found",
})
}
if (!message) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found",
});
}
return message
})
return message;
});

View File

@@ -1,9 +1,9 @@
import { router } from "../trpc"
import { listMessages, getMessage } from "./query"
import { resendMessage } from "./mutation"
import { router } from "../trpc";
import { listMessages, getMessage } from "./query";
import { resendMessage } from "./mutation";
export const messageRouter = router({
list: listMessages,
get: getMessage,
resend: resendMessage,
})
list: listMessages,
get: getMessage,
resend: resendMessage,
});

View File

@@ -1,84 +1,84 @@
import { prisma } from "../utils/prisma"
import { MessageStatus } from "../../prisma/client"
import { prisma } from "../utils/prisma";
import { MessageStatus } from "../../prisma/client";
interface MessageQueryOptions {
campaignId?: string
organizationId: string
status: MessageStatus | MessageStatus[]
campaignId?: string;
organizationId: string;
status: MessageStatus | MessageStatus[];
}
export async function findMessagesByStatus({
campaignId,
organizationId,
status,
campaignId,
organizationId,
status,
}: MessageQueryOptions) {
return prisma.message.findMany({
where: {
...(campaignId && { campaignId }),
Campaign: {
organizationId,
},
status: Array.isArray(status) ? { in: status } : status,
},
})
return prisma.message.findMany({
where: {
...(campaignId && { campaignId }),
Campaign: {
organizationId,
},
status: Array.isArray(status) ? { in: status } : status,
},
});
}
interface CampaignMessagesQueryOptions {
campaignId: string
organizationId: string
campaignId: string;
organizationId: string;
}
export async function getDeliveredMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: ["SENT", "CLICKED", "OPENED"],
})
return findMessagesByStatus({
campaignId,
organizationId,
status: ["SENT", "CLICKED", "OPENED"],
});
}
export async function getFailedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: "FAILED",
})
return findMessagesByStatus({
campaignId,
organizationId,
status: "FAILED",
});
}
export async function getOpenedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: ["OPENED", "CLICKED"], // Clicked implies opened
})
return findMessagesByStatus({
campaignId,
organizationId,
status: ["OPENED", "CLICKED"], // Clicked implies opened
});
}
export async function getClickedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: "CLICKED",
})
return findMessagesByStatus({
campaignId,
organizationId,
status: "CLICKED",
});
}
export async function getQueuedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: "QUEUED",
})
return findMessagesByStatus({
campaignId,
organizationId,
status: "QUEUED",
});
}

View File

@@ -1,98 +1,98 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import fs from "fs/promises"
import { TRPCError } from "@trpc/server"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import fs from "fs/promises";
import { TRPCError } from "@trpc/server";
const createOrganizationSchema = z.object({
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
})
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
});
export const createOrganization = authProcedure
.input(createOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const organization = await prisma.organization.create({
data: {
name: input.name,
description: input.description,
UserOrganizations: {
create: {
userId: ctx.user.id,
},
},
Templates: {
createMany: {
data: [
{
name: "Newsletter",
content: await fs.readFile(
"templates/newsletter.html",
"utf-8"
),
},
],
},
},
EmailDeliverySettings: {
// Default settings
create: {},
},
GeneralSettings: {
// Default settings
create: {},
},
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
},
})
.input(createOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const organization = await prisma.organization.create({
data: {
name: input.name,
description: input.description,
UserOrganizations: {
create: {
userId: ctx.user.id,
},
},
Templates: {
createMany: {
data: [
{
name: "Newsletter",
content: await fs.readFile(
"templates/newsletter.html",
"utf-8",
),
},
],
},
},
EmailDeliverySettings: {
// Default settings
create: {},
},
GeneralSettings: {
// Default settings
create: {},
},
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
},
});
return {
organization,
}
})
return {
organization,
};
});
const updateOrganizationSchema = z.object({
id: z.string(),
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
})
id: z.string(),
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
});
export const updateOrganization = authProcedure
.input(updateOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const userOrg = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.id,
},
})
.input(updateOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const userOrg = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.id,
},
});
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to update this organization.",
})
}
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to update this organization.",
});
}
const updatedOrganization = await prisma.organization.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
})
const updatedOrganization = await prisma.organization.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
});
return { organization: updatedOrganization }
})
return { organization: updatedOrganization };
});

View File

@@ -1,46 +1,46 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
export const getOrganizationById = authProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrg = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.id,
},
})
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrg = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.id,
},
});
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization.",
})
}
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization.",
});
}
const organization = await prisma.organization.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
})
const organization = await prisma.organization.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
});
if (!organization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found.",
})
}
if (!organization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found.",
});
}
return organization
})
return organization;
});

View File

@@ -1,9 +1,9 @@
import { router } from "../trpc"
import { createOrganization, updateOrganization } from "./mutation"
import { getOrganizationById } from "./query"
import { router } from "../trpc";
import { createOrganization, updateOrganization } from "./mutation";
import { getOrganizationById } from "./query";
export const organizationRouter = router({
create: createOrganization,
update: updateOrganization,
getById: getOrganizationById,
})
create: createOrganization,
update: updateOrganization,
getById: getOrganizationById,
});

View File

@@ -1,351 +1,351 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { randomBytes } from "crypto"
import { Mailer } from "../lib/Mailer"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { randomBytes } from "crypto";
import { Mailer } from "../lib/Mailer";
const smtpSchema = z.object({
organizationId: z.string(),
host: z.string().min(1, "SMTP host is required"),
port: z.number().min(1, "Port is required"),
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
fromEmail: z.string().email("Invalid email address").optional(),
fromName: z.string().optional(),
secure: z.boolean(),
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
})
organizationId: z.string(),
host: z.string().min(1, "SMTP host is required"),
port: z.number().min(1, "Port is required"),
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
fromEmail: z.string().email("Invalid email address").optional(),
fromName: z.string().optional(),
secure: z.boolean(),
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
});
export const updateSmtp = authProcedure
.input(smtpSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(smtpSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const smtpSettings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
})
const smtpSettings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
});
const settings = await prisma.smtpSettings.upsert({
where: {
id: smtpSettings ? smtpSettings.id : "create-happens",
},
create: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
organizationId: input.organizationId,
},
update: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
},
})
const settings = await prisma.smtpSettings.upsert({
where: {
id: smtpSettings ? smtpSettings.id : "create-happens",
},
create: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
organizationId: input.organizationId,
},
update: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
},
});
return { settings }
})
return { settings };
});
const emailDeliverySchema = z.object({
organizationId: z.string(),
rateLimit: z.number().min(1, "Rate limit is required"),
rateWindow: z.number().min(1, "Rate window is required"),
maxRetries: z.number().min(0, "Max retries must be 0 or greater"),
retryDelay: z.number().min(1, "Retry delay is required"),
concurrency: z.number().min(1, "Concurrency must be at least 1"),
connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"),
})
organizationId: z.string(),
rateLimit: z.number().min(1, "Rate limit is required"),
rateWindow: z.number().min(1, "Rate window is required"),
maxRetries: z.number().min(0, "Max retries must be 0 or greater"),
retryDelay: z.number().min(1, "Retry delay is required"),
concurrency: z.number().min(1, "Concurrency must be at least 1"),
connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"),
});
export const updateEmailDelivery = authProcedure
.input(emailDeliverySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(emailDeliverySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.emailDeliverySettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
organizationId: input.organizationId,
},
update: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
},
})
const settings = await prisma.emailDeliverySettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
organizationId: input.organizationId,
},
update: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
},
});
return { settings }
})
return { settings };
});
const generalSettingsSchema = z.object({
organizationId: z.string(),
defaultFromEmail: z.string().email().optional().or(z.literal("")),
defaultFromName: z.string().optional(),
baseURL: z.string().url().optional().or(z.literal("")),
cleanupInterval: z.coerce.number().int().min(1).optional(),
})
organizationId: z.string(),
defaultFromEmail: z.string().email().optional().or(z.literal("")),
defaultFromName: z.string().optional(),
baseURL: z.string().url().optional().or(z.literal("")),
cleanupInterval: z.coerce.number().int().min(1).optional(),
});
export const updateGeneral = authProcedure
.input(generalSettingsSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(generalSettingsSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.generalSettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
organizationId: input.organizationId,
},
update: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
},
})
const settings = await prisma.generalSettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
organizationId: input.organizationId,
},
update: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
},
});
return { settings }
})
return { settings };
});
const createApiKeySchema = z.object({
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
})
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
});
export const createApiKey = authProcedure
.input(createApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(createApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
let key = `sk_${randomBytes(32).toString("hex")}`
let key = `sk_${randomBytes(32).toString("hex")}`;
while (await prisma.apiKey.findUnique({ where: { key } })) {
key = `sk_${randomBytes(32).toString("hex")}`
}
while (await prisma.apiKey.findUnique({ where: { key } })) {
key = `sk_${randomBytes(32).toString("hex")}`;
}
const apiKey = await prisma.apiKey.create({
data: {
name: input.name,
key: key,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
organizationId: input.organizationId,
},
select: {
id: true,
key: true,
},
})
const apiKey = await prisma.apiKey.create({
data: {
name: input.name,
key: key,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
organizationId: input.organizationId,
},
select: {
id: true,
key: true,
},
});
return apiKey
})
return apiKey;
});
const deleteApiKeySchema = z.object({
organizationId: z.string(),
id: z.string(),
})
organizationId: z.string(),
id: z.string(),
});
export const deleteApiKey = authProcedure
.input(deleteApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(deleteApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
await prisma.apiKey.delete({
where: {
id: input.id,
organizationId: input.organizationId,
},
})
await prisma.apiKey.delete({
where: {
id: input.id,
organizationId: input.organizationId,
},
});
return { success: true }
})
return { success: true };
});
const createWebhookSchema = z.object({
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
url: z.string().url("Must be a valid URL"),
events: z.array(z.string()).min(1, "At least one event must be selected"),
isActive: z.boolean(),
secret: z.string().min(1, "Secret is required"),
})
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
url: z.string().url("Must be a valid URL"),
events: z.array(z.string()).min(1, "At least one event must be selected"),
isActive: z.boolean(),
secret: z.string().min(1, "Secret is required"),
});
export const createWebhook = authProcedure
.input(createWebhookSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(createWebhookSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
// const webhook = await prisma.webhook.create({
// data: {
// name: input.name,
// url: input.url,
// events: input.events,
// isActive: input.isActive,
// secret: input.secret,
// organizationId: input.organizationId,
// },
// })
// const webhook = await prisma.webhook.create({
// data: {
// name: input.name,
// url: input.url,
// events: input.events,
// isActive: input.isActive,
// secret: input.secret,
// organizationId: input.organizationId,
// },
// })
// TODO: Implement webhook creation
return { webhook: null }
})
// TODO: Implement webhook creation
return { webhook: null };
});
export const deleteWebhook = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
id: z.string(),
organizationId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
// TODO: Implement webhook deletion
return { success: true }
})
// TODO: Implement webhook deletion
return { success: true };
});
export const testSmtp = authProcedure
.input(
z.object({
email: z.string().email(),
organizationId: z.string(),
})
)
.mutation(async ({ input }) => {
const settings = await prisma.smtpSettings.findFirst({
where: {
organizationId: input.organizationId,
},
})
.input(
z.object({
email: z.string().email(),
organizationId: z.string(),
}),
)
.mutation(async ({ input }) => {
const settings = await prisma.smtpSettings.findFirst({
where: {
organizationId: input.organizationId,
},
});
if (!settings) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"SMTP settings not found. Please configure your SMTP settings first.",
})
}
if (!settings) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"SMTP settings not found. Please configure your SMTP settings first.",
});
}
const APP_NAME = "LetterSpace"
const APP_NAME = "LetterSpace";
const testTemplate = `
const testTemplate = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #1a1a1a; margin-bottom: 20px;">SMTP Test Email</h1>
<p style="color: #4a4a4a; line-height: 1.5;">
@@ -357,23 +357,23 @@ export const testSmtp = authProcedure
</p>
</div>
</div>
`
`;
const mailer = new Mailer(settings)
const mailer = new Mailer(settings);
const result = await mailer.sendEmail({
to: input.email,
subject: "SMTP Configuration Test",
html: testTemplate,
from: `${settings.fromName} <${settings.fromEmail}>`,
})
const result = await mailer.sendEmail({
to: input.email,
subject: "SMTP Configuration Test",
html: testTemplate,
from: `${settings.fromName} <${settings.fromEmail}>`,
});
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send test email",
})
}
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send test email",
});
}
return { success: true }
})
return { success: true };
});

View File

@@ -1,174 +1,174 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
export const getSmtp = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
})
const settings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
});
return settings
})
return settings;
});
export const getGeneral = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.generalSettings.findUnique({
where: {
organizationId: input.organizationId,
},
})
const settings = await prisma.generalSettings.findUnique({
where: {
organizationId: input.organizationId,
},
});
return settings
})
return settings;
});
export const listApiKeys = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const apiKeys = await prisma.apiKey.findMany({
where: {
organizationId: input.organizationId,
},
select: {
id: true,
name: true,
lastUsed: true,
expiresAt: true,
createdAt: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
})
const apiKeys = await prisma.apiKey.findMany({
where: {
organizationId: input.organizationId,
},
select: {
id: true,
name: true,
lastUsed: true,
expiresAt: true,
createdAt: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return apiKeys
})
return apiKeys;
});
export const listWebhooks = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
// TODO: Implement later
return []
// const webhooks = await prisma.webhook.findMany({
// where: {
// organizationId: input.organizationId,
// },
// orderBy: {
// createdAt: "desc",
// },
// })
// TODO: Implement later
return [];
// const webhooks = await prisma.webhook.findMany({
// where: {
// organizationId: input.organizationId,
// },
// orderBy: {
// createdAt: "desc",
// },
// })
// return webhooks
})
// return webhooks
});
export const getEmailDelivery = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.emailDeliverySettings.findUnique({
where: {
organizationId: input.organizationId,
},
})
const settings = await prisma.emailDeliverySettings.findUnique({
where: {
organizationId: input.organizationId,
},
});
return settings
})
return settings;
});

View File

@@ -1,37 +1,37 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
getSmtp,
getGeneral,
listApiKeys,
listWebhooks,
getEmailDelivery,
} from "./query"
getSmtp,
getGeneral,
listApiKeys,
listWebhooks,
getEmailDelivery,
} from "./query";
import {
updateSmtp,
testSmtp,
updateGeneral,
createApiKey,
deleteApiKey,
createWebhook,
deleteWebhook,
updateEmailDelivery,
} from "./mutation"
updateSmtp,
testSmtp,
updateGeneral,
createApiKey,
deleteApiKey,
createWebhook,
deleteWebhook,
updateEmailDelivery,
} from "./mutation";
export const settingsRouter = router({
getSmtp: getSmtp,
updateSmtp: updateSmtp,
testSmtp: testSmtp,
getGeneral: getGeneral,
updateGeneral: updateGeneral,
getSmtp: getSmtp,
updateSmtp: updateSmtp,
testSmtp: testSmtp,
getGeneral: getGeneral,
updateGeneral: updateGeneral,
// API Keys
createApiKey: createApiKey,
deleteApiKey: deleteApiKey,
listApiKeys: listApiKeys,
// API Keys
createApiKey: createApiKey,
deleteApiKey: deleteApiKey,
listApiKeys: listApiKeys,
createWebhook: createWebhook,
deleteWebhook: deleteWebhook,
listWebhooks: listWebhooks,
getEmailDelivery: getEmailDelivery,
updateEmailDelivery: updateEmailDelivery,
})
createWebhook: createWebhook,
deleteWebhook: deleteWebhook,
listWebhooks: listWebhooks,
getEmailDelivery: getEmailDelivery,
updateEmailDelivery: updateEmailDelivery,
});

View File

@@ -1,18 +1,18 @@
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime)
dayjs.extend(relativeTime);
// TODO: move this to a new package named "shared"
export function displayDate(date: Date) {
const dateObj = dayjs(date)
const dateObj = dayjs(date);
const daysFromNow = dateObj.diff(dayjs(), "day")
const daysFromNow = dateObj.diff(dayjs(), "day");
if (daysFromNow > 7) {
return dateObj.format("DD MMM YYYY")
}
if (daysFromNow > 7) {
return dateObj.format("DD MMM YYYY");
}
return dateObj.fromNow()
return dateObj.fromNow();
}

Some files were not shown because too many files have changed in this diff Show More