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

@@ -102,10 +102,7 @@
"filesystem": {
"type": "stdio",
"command": "pnpm",
"args": [
"mcp-server-filesystem",
"repos/compose"
],
"args": ["mcp-server-filesystem", "repos/compose"],
"env": {}
}
},

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@
!.zshenv
!.hushlogin
!.last_pwd
!biome.json
!/.github/
!/.github/**

View File

@@ -12,9 +12,7 @@
"Id": "Ant-Dark",
"License": "GPL 3+",
"Name": "Ant-Dark",
"ServiceTypes": [
"Plasma/LookAndFeel"
],
"ServiceTypes": ["Plasma/LookAndFeel"],
"Version": "0.1",
"Website": "https://github.com/EliverLara/Ant/tree/master/kde/Dark"
},

View File

@@ -12,9 +12,7 @@
"Id": "Nordic-bluish",
"License": "GPL 3+",
"Name": "Nordic-bluish",
"ServiceTypes": [
"Plasma/LookAndFeel"
],
"ServiceTypes": ["Plasma/LookAndFeel"],
"Version": "0.1",
"Website": "https://github.com/EliverLara/Nordic"
},

View File

@@ -12,9 +12,7 @@
"Id": "Nordic-darker",
"License": "GPL 3+",
"Name": "Nordic-darker",
"ServiceTypes": [
"Plasma/LookAndFeel"
],
"ServiceTypes": ["Plasma/LookAndFeel"],
"Version": "0.1",
"Website": "https://github.com/EliverLara/Nordic"
},

View File

@@ -12,9 +12,7 @@
"Id": "Nordic",
"License": "GPL 3+",
"Name": "Nordic",
"ServiceTypes": [
"Plasma/LookAndFeel"
],
"ServiceTypes": ["Plasma/LookAndFeel"],
"Version": "0.1",
"Website": "https://github.com/EliverLara/Nordic"
},

View File

@@ -1,19 +1,19 @@
function isDark(color) {
var r = color.r;
var g = color.g;
var b = color.b;
// Using the HSP value, determine whether the color is light or dark
var colorArray = [r, g , b ].map(v => {
var colorArray = [r, g, b].map((v) => {
if (v <= 0.03928) {
return v / 12.92
return v / 12.92;
}
return Math.pow((v + 0.055) / 1.055, 2.4)
})
return Math.pow((v + 0.055) / 1.055, 2.4);
});
var luminance = 0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2]
var luminance =
0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2];
return luminance <= 0.179
return luminance <= 0.179;
}

View File

@@ -17,6 +17,6 @@
"Version": "0.6",
"Website": "https://github.com/EliverLara/AndromedaLauncher"
},
"X-Plasma-Provides": [ "org.kde.plasma.launchermenu" ],
"X-Plasma-Provides": ["org.kde.plasma.launchermenu"],
"X-Plasma-API-Minimum-Version": "6.0"
}

View File

@@ -1,6 +1,5 @@
function updateBrightness(rootItem, source) {
if (rootItem.updateScreenBrightnessJob)
return;
if (rootItem.updateScreenBrightnessJob) return;
if (!source.data["PowerDevil"]) {
return;
@@ -9,7 +8,7 @@ function updateBrightness(rootItem, source) {
// we don't want passive brightness change send setBrightness call
rootItem.disableBrightnessUpdate = true;
if (typeof source.data["PowerDevil"]["Screen Brightness"] === 'number') {
if (typeof source.data["PowerDevil"]["Screen Brightness"] === "number") {
rootItem.screenBrightness = source.data["PowerDevil"]["Screen Brightness"];
}
rootItem.disableBrightnessUpdate = false;

View File

@@ -1,21 +1,20 @@
function isDark(color) {
//color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
var r = color.r;
var g = color.g;
var b = color.b;
var colorArray = [r, g , b ].map(v => {
var colorArray = [r, g, b].map((v) => {
if (v <= 0.03928) {
return v / 12.92
return v / 12.92;
}
return Math.pow((v + 0.055) / 1.055, 2.4)
})
return Math.pow((v + 0.055) / 1.055, 2.4);
});
var luminance = 0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2]
var luminance =
0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2];
return luminance <= 0.179
return luminance <= 0.179;
}

View File

@@ -3,8 +3,8 @@ function getBtDevice() {
var status = {
active: false,
message: ""
}
message: "",
};
for (var i = 0; i < btManager.devices.length; ++i) {
var device = btManager.devices[i];
@@ -34,8 +34,7 @@ function getBtDevice() {
return status;
}
function toggleBluetooth()
{
function toggleBluetooth() {
var enable = !btManager.bluetoothOperational;
btManager.bluetoothBlocked = !enable;
@@ -45,7 +44,6 @@ function toggleBluetooth()
}
}
function checkInhibition() {
var inhibited = false;
@@ -54,7 +52,7 @@ function checkInhibition() {
}
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
if (!isNaN(inhibitedUntil.getTime())) {
inhibited |= (Date.now() < inhibitedUntil.getTime());
inhibited |= Date.now() < inhibitedUntil.getTime();
}
if (notificationSettings.notificationsInhibitedByApplication) {
@@ -80,10 +78,10 @@ function toggleDnd() {
}
var d = new Date();
d.setYear(d.getFullYear()+1)
d.setYear(d.getFullYear() + 1);
notificationSettings.notificationsInhibitedUntil = d
notificationSettings.save()
notificationSettings.notificationsInhibitedUntil = d;
notificationSettings.save();
}
function revokeInhibitions() {
@@ -112,18 +110,23 @@ function toggleRedshiftInhibition() {
}
function volumePercent(volume) {
return volume / Vol.PulseAudio.NormalVolume * 100
return (volume / Vol.PulseAudio.NormalVolume) * 100;
}
function boundVolume(volume) {
return Math.max(Vol.PulseAudio.MinimalVolume, Math.min(volume, Vol.PulseAudio.NormalVolume));
return Math.max(
Vol.PulseAudio.MinimalVolume,
Math.min(volume, Vol.PulseAudio.NormalVolume),
);
}
function changeVolumeByPercent(volumeObject, deltaPercent) {
const oldVolume = volumeObject.volume;
const oldPercent = volumePercent(oldVolume);
const targetPercent = oldPercent + deltaPercent;
const newVolume = boundVolume(Math.round(Vol.PulseAudio.NormalVolume * (targetPercent/100)));
const newVolume = boundVolume(
Math.round(Vol.PulseAudio.NormalVolume * (targetPercent / 100)),
);
const newPercent = volumePercent(newVolume);
volumeObject.muted = newPercent == 0;
volumeObject.volume = newVolume;
@@ -134,7 +137,7 @@ function volIconName(volume, muted, prefix) {
prefix = "audio-volume";
}
var icon = null;
var percent = volume / Vol.PulseAudio.NormalVolume
var percent = volume / Vol.PulseAudio.NormalVolume;
if (percent <= 0.0 || muted) {
icon = prefix + "-muted";
} else if (percent <= 0.25) {
@@ -151,9 +154,11 @@ function getNetworkConnectionName() {
var status = network.networkStatus.activeConnections;
var statusParts;
if(isAirplane){ return "On"; }
if (isAirplane) {
return "On";
}
if(status && status !== "Disconnected") {
if (status && status !== "Disconnected") {
statusParts = status.split(":");
var connectionName = statusParts[1]?.trim().split(" ").slice(2).join(" ");
return connectionName || "Connected";

View File

@@ -1,9 +1,6 @@
function listProperty(item) {
for (var p in item)
{
if( typeof item[p] != "function" )
if(p != "objectName")
console.log(p + ":" + item[p]);
for (var p in item) {
if (typeof item[p] != "function")
if (p != "objectName") console.log(p + ":" + item[p]);
}
}

View File

@@ -18,9 +18,7 @@
"Name[de]": "KDE Kontrollzentrum",
"Name[ko]": "KDE 제어 센터",
"Name[pt_BR]": "Estação de controle KDE",
"ServiceTypes": [
"Plasma/Applet"
],
"ServiceTypes": ["Plasma/Applet"],
"Version": "0.1.0",
"Website": "https://github.com/EliverLara/kde-control-station/tree/plasma6"
},

View File

@@ -12,9 +12,7 @@
"postCreateCommand": "dir=/workspaces/ohmyzsh; rm -rf $HOME/.oh-my-zsh && ln -s $dir $HOME/.oh-my-zsh && cp $dir/templates/minimal.zshrc $HOME/.zshrc && chgrp -R 1000 $dir && chmod g-w,o-w $dir",
"customizations": {
"codespaces": {
"openFiles": [
"README.md"
]
"openFiles": ["README.md"]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
/* Custom keyframes for pulse */
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {

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',
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' },
{ 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' },
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Pivoine Docs',
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',
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',
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: React.ReactNode
children: React.ReactNode;
}) {
return (
<html lang="en" className="scroll-smooth">
<body className={inter.className}>{children}</body>
</html>
)
);
}

View File

@@ -1,49 +1,56 @@
'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)
}, [])
});
};
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'
}
]
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'
url: "http://pivoine.art",
gradient: "from-pink-500 to-rose-600",
},
{
title: 'Source Code',
title: "Source Code",
icon: Code2,
url: 'https://code.pivoine.art',
gradient: 'from-cyan-500 to-blue-600'
}
]
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">
@@ -53,18 +60,21 @@ export default function DocsHub() {
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'
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'
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
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 */}
@@ -73,7 +83,11 @@ export default function DocsHub() {
<header className="text-center mb-20 pt-12">
{/* Hero Icon */}
<div className="flex justify-center mb-8">
<PivoineDocsIcon size="200px" showLabel={false} interactive={true} />
<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">
@@ -86,7 +100,8 @@ export default function DocsHub() {
</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>.
Comprehensive documentation for all projects by{" "}
<span className="text-purple-400 font-semibold">Valknar</span>.
Explore technical guides, API references, and tutorials.
</p>
</header>
@@ -107,17 +122,29 @@ export default function DocsHub() {
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="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='' />
{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`}>
<div
className={`p-3 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg`}
>
<BookOpen className="w-8 h-8 text-white" />
</div>
)}
@@ -148,7 +175,9 @@ export default function DocsHub() {
<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>
<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>
@@ -166,7 +195,7 @@ export default function DocsHub() {
<div className="grid md:grid-cols-2 gap-6">
{links.map((link, idx) => {
const Icon = link.icon
const Icon = link.icon;
return (
<a
key={idx}
@@ -175,7 +204,9 @@ export default function DocsHub() {
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`}>
<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">
@@ -186,7 +217,7 @@ export default function DocsHub() {
</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>
@@ -194,11 +225,17 @@ export default function DocsHub() {
{/* 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>
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,269 +1,368 @@
'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'
}}>
<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'
}}>
<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'
}}>
<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'
}}>
<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' }}>
<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'
}}>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "320px",
}}
>
<PivoineDocsIcon size="280px" />
</div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
<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' }}>
<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'
}}>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "320px",
}}
>
<PivoineDocsIcon size="240px" showLabel />
</div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
<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' }}>
<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'
}}>
<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' }}>
<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'
}}>
<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' }}>
<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
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
64px
<br />
Favicon
</p>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="96px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
96px<br />Small
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
96px
<br />
Small
</p>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="128px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
128px<br />Medium
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
128px
<br />
Medium
</p>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="192px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
192px<br />Large
<p
style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
192px
<br />
Large
</p>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="256px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
256px<br />X-Large
<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'
}}>
<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'
}}>
<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: "Realistic Design",
description: "Multi-layered peony with natural gradients",
},
{
icon: '✨',
title: 'Smooth Animations',
description: 'Gentle breathing in normal state'
icon: "✨",
title: "Smooth Animations",
description: "Gentle breathing in normal state",
},
{
icon: '🎭',
title: 'Interactive States',
description: 'Bloom on hover, close on click'
icon: "🎭",
title: "Interactive States",
description: "Bloom on hover, close on click",
},
{
icon: '💫',
title: 'Particle Effects',
description: '12 bloom particles flying around'
icon: "💫",
title: "Particle Effects",
description: "12 bloom particles flying around",
},
{
icon: '🎨',
title: 'Beautiful Colors',
description: 'Pink to purple gradient palette'
icon: "🎨",
title: "Beautiful Colors",
description: "Pink to purple gradient palette",
},
{
icon: '♿',
title: 'Accessible',
description: 'Reduced motion & touch support'
icon: "♿",
title: "Accessible",
description: "Reduced motion & touch support",
},
{
icon: '📱',
title: 'Responsive',
description: 'Works perfectly on all devices'
icon: "📱",
title: "Responsive",
description: "Works perfectly on all devices",
},
{
icon: '⚡',
title: 'High Performance',
description: 'GPU-accelerated CSS animations'
}
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' }}>
<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'
}}>
<h4
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
color: "#fda4af",
}}
>
{feature.title}
</h4>
<p style={{
fontSize: '0.875rem',
color: '#94a3b8'
}}>
<p
style={{
fontSize: "0.875rem",
color: "#94a3b8",
}}
>
{feature.description}
</p>
</div>
@@ -272,31 +371,37 @@ export default function PivoineIconDemo() {
</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'
}}>
<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'
<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" />
@@ -310,15 +415,17 @@ export default function PivoineIconDemo() {
</div>
{/* Footer */}
<div style={{
marginTop: '4rem',
textAlign: 'center',
color: '#64748b',
fontSize: '0.875rem'
}}>
<div
style={{
marginTop: "4rem",
textAlign: "center",
color: "#64748b",
fontSize: "0.875rem",
}}
>
<p>Made with 🌸 for beautiful documentation experiences</p>
</div>
</div>
</div>
)
);
}

View File

@@ -47,7 +47,8 @@
}
.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 {

View File

@@ -1,48 +1,50 @@
'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',
size = "192px",
interactive = true,
className = ''
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
if (!interactive) return;
setIsClicked(true)
setShowRipple(true)
setIsClicked(true);
setShowRipple(true);
setTimeout(() => {
setIsClicked(false)
}, 600)
setIsClicked(false);
}, 600);
setTimeout(() => {
setShowRipple(false)
}, 800)
}
setShowRipple(false);
}, 800);
};
const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return
handleClick()
}
if (!interactive) return;
handleClick();
};
const wrapperClasses = [
'kompose-icon-wrapper',
isClicked && 'is-clicked',
interactive && 'is-interactive',
className
].filter(Boolean).join(' ')
"kompose-icon-wrapper",
isClicked && "is-clicked",
interactive && "is-interactive",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
@@ -58,23 +60,58 @@ export default function KomposeIcon({
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern id="carbon192" x="0" y="0" width="7.68" height="7.68" patternUnits="userSpaceOnUse">
<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>
<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>
<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
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>
<feGaussianBlur
stdDeviation="6"
result="coloredBlur"
></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
@@ -82,7 +119,10 @@ export default function KomposeIcon({
</filter>
<filter id="intenseglow192">
<feGaussianBlur stdDeviation="12" result="coloredBlur"></feGaussianBlur>
<feGaussianBlur
stdDeviation="12"
result="coloredBlur"
></feGaussianBlur>
<feMerge>
<feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode>
@@ -91,29 +131,124 @@ export default function KomposeIcon({
</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>
<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>
<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>
<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>
<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>
)
);
}

View File

@@ -99,14 +99,30 @@
animation: stamen-pulse 3s ease-in-out infinite;
}
.stamen-0 { animation-delay: 0s; }
.stamen-1 { animation-delay: 0.2s; }
.stamen-2 { animation-delay: 0.4s; }
.stamen-3 { animation-delay: 0.6s; }
.stamen-4 { animation-delay: 0.8s; }
.stamen-5 { animation-delay: 1s; }
.stamen-6 { animation-delay: 1.2s; }
.stamen-7 { animation-delay: 1.4s; }
.stamen-0 {
animation-delay: 0s;
}
.stamen-1 {
animation-delay: 0.2s;
}
.stamen-2 {
animation-delay: 0.4s;
}
.stamen-3 {
animation-delay: 0.6s;
}
.stamen-4 {
animation-delay: 0.8s;
}
.stamen-5 {
animation-delay: 1s;
}
.stamen-6 {
animation-delay: 1.2s;
}
.stamen-7 {
animation-delay: 1.4s;
}
/* Sparkles twinkle */
.sparkle {
@@ -114,14 +130,30 @@
transform-origin: center;
}
.sparkle-1 { animation-delay: 0s; }
.sparkle-2 { animation-delay: 0.4s; }
.sparkle-3 { animation-delay: 0.8s; }
.sparkle-4 { animation-delay: 1.2s; }
.sparkle-5 { animation-delay: 1.6s; }
.sparkle-6 { animation-delay: 2s; }
.sparkle-7 { animation-delay: 2.4s; }
.sparkle-8 { animation-delay: 2.8s; }
.sparkle-1 {
animation-delay: 0s;
}
.sparkle-2 {
animation-delay: 0.4s;
}
.sparkle-3 {
animation-delay: 0.8s;
}
.sparkle-4 {
animation-delay: 1.2s;
}
.sparkle-5 {
animation-delay: 1.6s;
}
.sparkle-6 {
animation-delay: 2s;
}
.sparkle-7 {
animation-delay: 2.4s;
}
.sparkle-8 {
animation-delay: 2.8s;
}
/* Pages subtle floating */
.page {
@@ -129,20 +161,36 @@
animation: page-float 4s ease-in-out infinite;
}
.page-1 { animation-delay: 0s; }
.page-2 { animation-delay: 0.3s; }
.page-3 { animation-delay: 0.6s; }
.page-1 {
animation-delay: 0s;
}
.page-2 {
animation-delay: 0.3s;
}
.page-3 {
animation-delay: 0.6s;
}
/* Text lines subtle shimmer */
.text-line {
animation: text-shimmer 4s ease-in-out infinite;
}
.line-1 { animation-delay: 0s; }
.line-2 { animation-delay: 0.3s; }
.line-3 { animation-delay: 0.6s; }
.line-4 { animation-delay: 0.9s; }
.line-5 { animation-delay: 1.2s; }
.line-1 {
animation-delay: 0s;
}
.line-2 {
animation-delay: 0.3s;
}
.line-3 {
animation-delay: 0.6s;
}
.line-4 {
animation-delay: 0.9s;
}
.line-5 {
animation-delay: 1.2s;
}
/* Bloom particles hidden in normal state */
.bloom-particle {
@@ -179,7 +227,7 @@
}
.pivoine-docs-icon-wrapper.is-interactive.is-hovered .inner-petal {
transform: scale(1.0) translateX(16px);
transform: scale(1) translateX(16px);
opacity: 0.95;
filter: url(#petal-glow);
animation: petal-bloom-hover 2s ease-in-out infinite 0.4s;
@@ -273,7 +321,8 @@
/* Normal State Animations */
@keyframes bg-breathe {
0%, 100% {
0%,
100% {
opacity: 0.06;
transform: scale(1);
}
@@ -284,7 +333,8 @@
}
@keyframes petal-breathe {
0%, 100% {
0%,
100% {
transform: scale(0.3) translateX(8px);
}
50% {
@@ -293,7 +343,8 @@
}
@keyframes petal-float {
0%, 100% {
0%,
100% {
transform: scale(0.75) translateX(20px) translateY(0);
}
50% {
@@ -302,7 +353,8 @@
}
@keyframes center-breathe {
0%, 100% {
0%,
100% {
transform: scale(1);
opacity: 1;
}
@@ -313,7 +365,8 @@
}
@keyframes center-breathe-inner {
0%, 100% {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
@@ -333,7 +386,8 @@
}
@keyframes stamen-pulse {
0%, 100% {
0%,
100% {
transform: scale(1);
opacity: 0.8;
}
@@ -344,7 +398,8 @@
}
@keyframes sparkle-twinkle {
0%, 100% {
0%,
100% {
transform: scale(0.8);
opacity: 0.4;
}
@@ -355,7 +410,8 @@
}
@keyframes page-float {
0%, 100% {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
@@ -364,7 +420,8 @@
}
@keyframes text-shimmer {
0%, 100% {
0%,
100% {
opacity: 0.7;
}
50% {
@@ -374,7 +431,8 @@
/* Hover State Animations */
@keyframes petal-bloom-hover {
0%, 100% {
0%,
100% {
transform: scale(0.55) translateX(38px) rotate(0deg);
}
50% {
@@ -383,7 +441,8 @@
}
@keyframes center-bloom-glow {
0%, 100% {
0%,
100% {
transform: scale(1.3);
opacity: 1;
}
@@ -394,7 +453,8 @@
}
@keyframes center-bloom-inner {
0%, 100% {
0%,
100% {
transform: scale(1.4);
opacity: 1;
}
@@ -405,7 +465,8 @@
}
@keyframes stamen-dance {
0%, 100% {
0%,
100% {
transform: scale(1) translateY(0);
}
50% {
@@ -437,8 +498,7 @@
opacity: 0.7;
}
50% {
transform:
translate(
transform: translate(
calc(cos(var(--particle-angle)) * var(--particle-distance)),
calc(sin(var(--particle-angle)) * var(--particle-distance))
)
@@ -449,8 +509,7 @@
opacity: 0.5;
}
100% {
transform:
translate(
transform: translate(
calc(cos(var(--particle-angle)) * var(--particle-distance) * 1.5),
calc(sin(var(--particle-angle)) * var(--particle-distance) * 1.5)
)
@@ -460,7 +519,8 @@
}
@keyframes bg-bloom-glow {
0%, 100% {
0%,
100% {
opacity: 0.15;
transform: scale(1.05);
}
@@ -499,7 +559,8 @@
}
@keyframes icon-pulse {
0%, 100% {
0%,
100% {
transform: scale(1);
}
50% {
@@ -603,8 +664,7 @@
opacity: 0.7;
}
30% {
transform:
translate(
transform: translate(
calc(cos(var(--particle-angle)) * var(--particle-distance) * 2),
calc(sin(var(--particle-angle)) * var(--particle-distance) * 2)
)
@@ -612,8 +672,7 @@
opacity: 1;
}
100% {
transform:
translate(
transform: translate(
calc(cos(var(--particle-angle)) * var(--particle-distance) * 0.3),
calc(sin(var(--particle-angle)) * var(--particle-distance) * 0.3)
)
@@ -641,7 +700,8 @@
}
@keyframes label-shimmer {
0%, 100% {
0%,
100% {
filter: brightness(1);
}
50% {
@@ -690,7 +750,9 @@
.label-text,
.bg-glow {
animation: none !important;
transition: opacity 0.3s ease, transform 0.3s ease !important;
transition:
opacity 0.3s ease,
transform 0.3s ease !important;
}
.pivoine-docs-icon-wrapper.is-interactive.is-hovered {

View File

@@ -1,64 +1,66 @@
'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',
size = "256px",
interactive = true,
className = '',
showLabel = false
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)
}
if (!interactive) return;
setIsHovered(true);
};
const handleMouseLeave = () => {
if (!interactive) return
setIsHovered(false)
}
if (!interactive) return;
setIsHovered(false);
};
const handleClick = () => {
if (!interactive) return
if (!interactive) return;
setIsClicked(true)
setIsClicked(true);
setTimeout(() => {
setIsClicked(false)
}, 1200)
}
setIsClicked(false);
}, 1200);
};
const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return
e.preventDefault()
setIsHovered(true)
if (!interactive) return;
e.preventDefault();
setIsHovered(true);
setTimeout(() => {
handleClick()
}, 50)
handleClick();
}, 50);
setTimeout(() => {
setIsHovered(false)
}, 1500)
}
setIsHovered(false);
}, 1500);
};
const wrapperClasses = [
'pivoine-docs-icon-wrapper',
isHovered && 'is-hovered',
isClicked && 'is-clicked',
interactive && 'is-interactive',
className
].filter(Boolean).join(' ')
"pivoine-docs-icon-wrapper",
isHovered && "is-hovered",
isClicked && "is-clicked",
interactive && "is-interactive",
className,
]
.filter(Boolean)
.join(" ");
// Generate bloom particles with varied properties
const bloomParticles = Array.from({ length: 12 }, (_, i) => ({
@@ -67,7 +69,7 @@ export default function PivoineDocsIcon({
distance: 80 + Math.random() * 20,
size: 2 + Math.random() * 2,
delay: i * 0.08,
}))
}));
return (
<div
@@ -76,7 +78,7 @@ export default function PivoineDocsIcon({
onMouseLeave={handleMouseLeave}
onClick={handleClick}
onTouchStart={handleTouch}
style={{ width: size, height: size, rotate: '5deg' }}
style={{ width: size, height: size, rotate: "5deg" }}
>
<svg
className="pivoine-docs-icon"
@@ -87,49 +89,130 @@ export default function PivoineDocsIcon({
<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 }} />
<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 }} />
<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 }} />
<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-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 }} />
<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-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 }} />
<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="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 }} />
<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>
<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
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>
{/* Enhanced Filters */}
@@ -175,7 +258,14 @@ export default function PivoineDocsIcon({
</defs>
{/* Subtle background glow */}
<circle className="bg-glow" cx="128" cy="128" r="120" fill="url(#petal-gradient-3)" opacity="0.08" />
<circle
className="bg-glow"
cx="128"
cy="128"
r="120"
fill="url(#petal-gradient-3)"
opacity="0.08"
/>
{/* Outer layer - Large petals (8 petals) */}
<g className="outer-petals">
@@ -198,7 +288,11 @@ export default function PivoineDocsIcon({
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`}}
style={{
rotate: `${petal.angle}deg`,
width: `${128 * petal.scaleX}px`,
height: `${70 * petal.scaleY}px`,
}}
/>
))}
</g>
@@ -224,7 +318,11 @@ export default function PivoineDocsIcon({
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`}}
style={{
rotate: `${petal.angle}deg`,
width: `${128 * petal.scaleX}px`,
height: `${70 * petal.scaleY}px`,
}}
/>
))}
</g>
@@ -250,7 +348,7 @@ export default function PivoineDocsIcon({
ry="44"
fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)"
style={{rotate: `${petal.angle}deg`}}
style={{ rotate: `${petal.angle}deg` }}
/>
))}
</g>
@@ -276,9 +374,9 @@ export default function PivoineDocsIcon({
{/* 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
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}`}
@@ -289,20 +387,76 @@ export default function PivoineDocsIcon({
fill="#d97706"
opacity="0.8"
/>
)
);
})}
</g>
{/* 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)" />
<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>
{/* Flying bloom particles (visible on hover) */}
@@ -317,11 +471,13 @@ export default function PivoineDocsIcon({
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}
style={
{
"--particle-angle": `${particle.angle}deg`,
"--particle-distance": `${particle.distance}px`,
"--particle-delay": `${particle.delay}s`,
} as React.CSSProperties
}
/>
))}
</g>
@@ -334,5 +490,5 @@ export default function PivoineDocsIcon({
</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

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

View File

@@ -2,4 +2,4 @@ export default {
plugins: {
"@tailwindcss/postcss": {},
},
}
};

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}',
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
}
};

View File

@@ -4,60 +4,55 @@
// gem install scss-lint
module.exports = function (grunt) {
'use strict';
"use strict";
// Project configuration
grunt.initConfig({
// Metadata
pkg: grunt.file.readJSON('package.json'),
banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' +
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',
" Licensed <%= props.license %> */\n",
webfont: {
icons: {
src: [
'icons/sbed/*.svg',
'icons/lorc/*.svg'
],
dest: 'fonts',
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',
styles: "font,icon,extra",
fontFilename: "game-icons",
types: ["eot", "woff2", "woff", "ttf", "svg"],
syntax: "bootstrap",
destCss: "css",
destScss: "scss",
templateOptions: {
baseClass: 'gi',
classPrefix: 'gi-'
baseClass: "gi",
classPrefix: "gi-",
},
fontFamilyName: 'GameIcons',
font: 'game-icons',
stylesheets: ['css', 'scss'],
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'
}
}
}
"css/game-icons.min.css": "css/game-icons.css",
},
},
},
});
// These plugins provide necessary tasks
grunt.loadNpmTasks('grunt-webfont');
grunt.loadNpmTasks('grunt-contrib-cssmin');
grunt.loadNpmTasks("grunt-webfont");
grunt.loadNpmTasks("grunt-contrib-cssmin");
grunt.registerTask('default', [
'webfont',
'cssmin'
]);
grunt.registerTask("default", ["webfont", "cssmin"]);
};

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'
primary: "emerald",
secondary: "fuchsia",
neutral: "zinc",
},
footer: {
slots: {
root: 'border-t border-default',
left: 'text-sm text-muted'
}
}
root: "border-t border-default",
left: "text-sm text-muted",
},
},
},
seo: {
siteName: 'Kompose'
siteName: "Kompose",
},
header: {
title: '',
to: '/',
title: "",
to: "/",
logo: {
alt: '',
light: '',
dark: ''
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'
}]
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'
}]
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',
title: "Table of Contents",
bottom: {
title: 'Community',
edit: 'https://code.pivoine.art/valknar/kompose/src/branch/main/docs/content',
links: []
}
}
})
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' }
],
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
htmlAttrs: {
lang: 'en'
}
})
lang: "en",
},
});
useSeoMeta({
titleTemplate: `%s - ${seo?.siteName}`,
ogSiteName: seo?.siteName,
twitterCard: 'summary_large_image'
})
twitterCard: "summary_large_image",
});
provide('navigation', navigation)
provide("navigation", navigation);
</script>
<template>

View File

@@ -5,19 +5,19 @@
@theme static {
--container-8xl: 90rem;
--font-sans: 'Public Sans', sans-serif;
--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-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-900: #0a5331;
--color-green-950: #052e16;
}
:root {

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)
isClicked.value = false;
}, 600);
setTimeout(() => {
showRipple.value = false
}, 800)
}
showRipple.value = false;
}, 800);
};
const handleHover = () => {
if (!props.interactive) return
if (!props.interactive) return;
// Hover animations are handled by CSS
}
};
const handleLeave = () => {
if (!props.interactive) return
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.
}
})
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',
label: "Copy Markdown link",
icon: "i-lucide-link",
onSelect() {
copy(mdPath.value)
copy(mdPath.value);
toast.add({
title: 'Copied to clipboard',
icon: 'i-lucide-check-circle'
})
}
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: "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 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: "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
appear.value = true;
setTimeout(() => {
appeared.value = true
}, 1000)
}, 0)
})
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 }
}>(), {
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',
color: "var(--ui-primary)",
speed: "normal",
size: () => ({
min: 1,
max: 2
})
})
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'
size:
typeof props.size === "number"
? props.size
: Math.random() * (props.size.max - props.size.min) + props.size.min
}))
}
: 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 }
}
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', () => {
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))
}
})
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.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'
}
})
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
})
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 = []
const links = [];
if (toc?.bottom?.edit) {
links.push({
icon: 'i-lucide-external-link',
label: 'Edit this page',
icon: "i-lucide-external-link",
label: "Edit this page",
to: `${toc.bottom.edit}/${page?.value?.stem}.${page?.value?.extension}`,
target: '_blank'
})
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'
type: "page",
source: "index.md",
}),
docs: defineCollection({
type: 'page',
type: "page",
source: {
include: '**',
include: "**",
},
schema: z.object({
links: z.array(z.object({
links: z
.array(
z.object({
label: z.string(),
icon: z.string(),
to: z.string(),
target: z.string().optional()
})).optional()
})
})
}
})
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
)
);

View File

@@ -1,15 +1,15 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
app: {
baseURL: '/kompose/',
baseURL: "/kompose/",
},
modules: [
'@nuxt/eslint',
'@nuxt/image',
'@nuxt/ui',
'@nuxt/content',
'nuxt-og-image',
'nuxt-llms'
"@nuxt/eslint",
"@nuxt/image",
"@nuxt/ui",
"@nuxt/content",
"nuxt-og-image",
"nuxt-llms",
],
// content: {
@@ -35,69 +35,68 @@ export default defineNuxtConfig({
// },
devtools: {
enabled: false
enabled: false,
},
css: ['~/assets/css/main.css'],
css: ["~/assets/css/main.css"],
content: {
build: {
markdown: {
toc: {
searchDepth: 1
}
}
}
searchDepth: 1,
},
},
},
},
compatibilityDate: '2024-07-11',
compatibilityDate: "2024-07-11",
nitro: {
prerender: {
routes: [
'/'
],
routes: ["/"],
crawlLinks: true,
autoSubfolderIndex: false
}
autoSubfolderIndex: false,
},
},
eslint: {
config: {
stylistic: {
commaDangle: 'never',
braceStyle: '1tbs'
}
}
commaDangle: "never",
braceStyle: "1tbs",
},
},
},
icon: {
provider: 'iconify'
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.',
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.'
title: "Nuxt Docs Template - Full Documentation",
description: "This is the full documentation for the Nuxt Docs Template.",
},
sections: [
{
title: 'Getting Started',
contentCollection: 'docs',
title: "Getting Started",
contentCollection: "docs",
contentFilters: [
{ field: 'path', operator: 'LIKE', value: '/getting-started%' }
]
{ field: "path", operator: "LIKE", value: "/getting-started%" },
],
},
{
title: 'Essentials',
contentCollection: 'docs',
title: "Essentials",
contentCollection: "docs",
contentFilters: [
{ field: 'path', operator: 'LIKE', value: '/essentials%' }
]
}
]
}
})
{ field: "path", operator: "LIKE", value: "/essentials%" },
],
},
],
},
});

View File

@@ -1,13 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"extends": ["github>nuxt/renovate-config-nuxt"],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"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()
const page = await queryCollection(event, "docs" as keyof Collections)
.path(path)
.first();
if (!page) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
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])
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,6 +1,6 @@
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}"],
@@ -22,4 +22,4 @@ export default tseslint.config({
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
"no-unused-vars": "off",
},
})
});

View File

@@ -1,7 +1,7 @@
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())) {
@@ -27,7 +27,7 @@ async function seed() {
},
},
},
})
});
}
const orgId = (
@@ -36,10 +36,10 @@ async function seed() {
createdAt: "asc",
},
})
)?.id
)?.id;
if (!orgId) {
throw new Error("not reachable")
throw new Error("not reachable");
}
if (!(await prisma.user.findFirst())) {
@@ -54,7 +54,7 @@ async function seed() {
},
},
},
})
});
}
// Create 5000 subscribers
@@ -63,17 +63,17 @@ async function seed() {
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()
const now = new Date();
for (let d = 0; d < 10; d++) {
const day = dayjs(now)
.subtract(d + 1, "day")
.toDate()
.toDate();
const dailySubs = Array.from({ length: 10 }, (_, i) => ({
name: `DailySub ${d + 1}-${i + 1}`,
@@ -81,19 +81,19 @@ async function seed() {
organizationId: orgId,
createdAt: day,
updatedAt: day,
}))
}));
await prisma.subscriber.createMany({
data: dailySubs,
skipDuplicates: true,
})
});
}
}
seed()
.then(async () => {
await prisma.$disconnect()
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
})
console.error(e);
await prisma.$disconnect();
});

View File

@@ -1,26 +1,26 @@
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
next: NextFunction,
) => {
const apiKey = req.header("x-api-key")
const apiKey = req.header("x-api-key");
if (!apiKey) {
res.status(401).json({ error: "Missing API Key" })
return
res.status(401).json({ error: "Missing API Key" });
return;
}
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
res.status(401).json({ error: "Invalid API Key" });
return;
}
// Update lastUsed timestamp asynchronously, don't await
@@ -33,14 +33,14 @@ export const authenticateApiKey = async (
// Log the error but don't block the request
console.error(
"Failed to update API key lastUsed timestamp",
updateError
)
})
updateError,
);
});
req.organization = keyRecord.Organization
next()
req.organization = keyRecord.Organization;
next();
} catch (error) {
console.error("Error validating API key", error)
res.status(500).json({ error: "Server error" })
console.error("Error validating API key", error);
res.status(500).json({ error: "Server error" });
}
}
};

View File

@@ -1,17 +1,17 @@
import express from "express"
import { prisma } from "../utils/prisma"
import { authenticateApiKey } from "./middleware"
import { z } from "zod"
import { Prisma } from "../../prisma/client"
import crypto from "crypto"
import { Mailer } from "../lib/Mailer"
import fs from "fs/promises"
import path from "path"
import dayjs from "dayjs"
import express from "express";
import { prisma } from "../utils/prisma";
import { authenticateApiKey } from "./middleware";
import { z } from "zod";
import { Prisma } from "../../prisma/client";
import crypto from "crypto";
import { Mailer } from "../lib/Mailer";
import fs from "fs/promises";
import path from "path";
import dayjs from "dayjs";
export const apiRouter = express.Router()
export const apiRouter = express.Router();
apiRouter.use(authenticateApiKey)
apiRouter.use(authenticateApiKey);
/**
* @swagger
@@ -123,13 +123,13 @@ apiRouter.post("/subscribers", async (req, res) => {
emailVerified: z.boolean().optional(),
metadata: z.record(z.string(), z.string()).optional(),
})
.safeParse(req.body)
.safeParse(req.body);
if (validationError) {
res.status(400).json({
error: validationError.issues[0]?.message || "Invalid input data",
})
return
});
return;
}
const {
@@ -139,7 +139,7 @@ apiRouter.post("/subscribers", async (req, res) => {
doubleOptIn,
emailVerified,
metadata: newMetadata,
} = body
} = body;
const existingLists = await prisma.list.findMany({
where: {
@@ -148,13 +148,15 @@ apiRouter.post("/subscribers", async (req, res) => {
},
organizationId: req.organization.id,
},
})
});
if (existingLists.length !== lists.length) {
const foundListIds = existingLists.map((list) => list.id)
const missingListId = lists.find((id) => !foundListIds.includes(id))
res.status(400).json({ error: `List with id ${missingListId} not found` })
return
const foundListIds = existingLists.map((list) => list.id);
const missingListId = lists.find((id) => !foundListIds.includes(id));
res
.status(400)
.json({ error: `List with id ${missingListId} not found` });
return;
}
const existingSubscriber = await prisma.subscriber.findFirst({
@@ -170,92 +172,92 @@ apiRouter.post("/subscribers", async (req, res) => {
},
Metadata: true,
},
})
});
const existingListIds =
existingSubscriber?.ListSubscribers.map((list) => list.List.id) || []
const allLists = existingListIds.concat(lists)
existingSubscriber?.ListSubscribers.map((list) => list.List.id) || [];
const allLists = existingListIds.concat(lists);
const uniqueLists = [...new Set(allLists)]
const uniqueLists = [...new Set(allLists)];
const isExpired = existingSubscriber?.emailVerificationTokenExpiresAt
? dayjs(existingSubscriber.emailVerificationTokenExpiresAt).isBefore(
dayjs()
dayjs(),
)
: true
: true;
const shouldSendVerificationEmail =
doubleOptIn && !existingSubscriber?.emailVerified && isExpired
doubleOptIn && !existingSubscriber?.emailVerified && isExpired;
if (shouldSendVerificationEmail) {
const emailVerificationToken = crypto.randomBytes(32).toString("hex")
const emailVerificationTokenExpiresAt = dayjs().add(24, "hours").toDate()
const emailVerified = false
const emailVerificationToken = crypto.randomBytes(32).toString("hex");
const emailVerificationTokenExpiresAt = dayjs().add(24, "hours").toDate();
const emailVerified = false;
try {
const smtpSettings = await prisma.smtpSettings.findFirst({
where: { organizationId: req.organization.id },
})
});
if (!smtpSettings) {
console.error(
`SMTP settings not found for organization ${req.organization.id}.`
)
`SMTP settings not found for organization ${req.organization.id}.`,
);
res.status(422).json({
error:
"SMTP settings not configured for this organization. Cannot send verification email.",
})
return
});
return;
}
const generalSettings = await prisma.generalSettings.findFirst({
where: { organizationId: req.organization.id },
})
});
if (!generalSettings || !generalSettings.baseURL) {
console.error(
`General settings (especially baseURL) not found for organization ${req.organization.id}.`
)
`General settings (especially baseURL) not found for organization ${req.organization.id}.`,
);
res.status(422).json({
error:
"Base URL not configured in general settings for this organization. Cannot send verification email.",
})
return
});
return;
}
const fromEmailAddress =
smtpSettings.fromEmail || generalSettings.defaultFromEmail
smtpSettings.fromEmail || generalSettings.defaultFromEmail;
if (!fromEmailAddress) {
console.error(
`Sender email (fromEmail/defaultFromEmail) not configured for organization ${req.organization.id}.`
)
`Sender email (fromEmail/defaultFromEmail) not configured for organization ${req.organization.id}.`,
);
res.status(422).json({
error:
"Sender email not configured for this organization. Cannot send verification email.",
})
return
});
return;
}
const mailer = new Mailer(smtpSettings)
const verificationLink = `${generalSettings.baseURL.replace(/\/$/, "")}/verify-email?token=${emailVerificationToken}`
const mailer = new Mailer(smtpSettings);
const verificationLink = `${generalSettings.baseURL.replace(/\/$/, "")}/verify-email?token=${emailVerificationToken}`;
const templatePath = path.join(
__dirname,
"../../templates/verificationEmail.html"
)
let emailHtmlContent = await fs.readFile(templatePath, "utf-8")
"../../templates/verificationEmail.html",
);
let emailHtmlContent = await fs.readFile(templatePath, "utf-8");
emailHtmlContent = emailHtmlContent
.replace(/{{name}}/g, name || "there")
.replace(/{{verificationLink}}/g, verificationLink)
.replace(/{{currentYear}}/g, new Date().getFullYear().toString())
.replace(/{{currentYear}}/g, new Date().getFullYear().toString());
await mailer.sendEmail({
to: email,
from: fromEmailAddress,
subject: "Verify Your Email Address",
html: emailHtmlContent,
})
});
const subscriber = await prisma.subscriber.upsert({
where: { id: existingSubscriber?.id || "create" },
@@ -310,7 +312,7 @@ apiRouter.post("/subscribers", async (req, res) => {
createdAt: true,
updatedAt: true,
},
})
});
const data = {
id: subscriber.id,
@@ -323,29 +325,29 @@ apiRouter.post("/subscribers", async (req, res) => {
})),
metadata: subscriber.Metadata.reduce(
(acc, meta) => {
acc[meta.key] = meta.value
return acc
acc[meta.key] = meta.value;
return acc;
},
{} as Record<string, string>
{} as Record<string, string>,
),
emailVerified: subscriber.emailVerified,
createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt,
}
};
console.log("data", data)
console.log("data", data);
res.status(201).json(data)
return
res.status(201).json(data);
return;
} catch (emailError: any) {
console.error(
`Error sending verification email to ${email}:`,
emailError
)
emailError,
);
res.status(422).json({
error: `Failed to send verification email: ${emailError.message || "Unknown reason"}`,
})
return
});
return;
}
}
@@ -404,7 +406,7 @@ apiRouter.post("/subscribers", async (req, res) => {
},
Metadata: true,
},
})
});
const data = {
id: subscriber.id,
@@ -417,22 +419,22 @@ apiRouter.post("/subscribers", async (req, res) => {
})),
metadata: subscriber.Metadata.reduce(
(acc, meta) => {
acc[meta.key] = meta.value
return acc
acc[meta.key] = meta.value;
return acc;
},
{} as Record<string, string>
{} as Record<string, string>,
),
emailVerified: subscriber.emailVerified,
createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt,
}
};
res.status(201).json(data)
res.status(201).json(data);
} catch (error) {
console.error("Error creating subscriber", error)
res.status(500).json({ error: "Server error" })
console.error("Error creating subscriber", error);
res.status(500).json({ error: "Server error" });
}
})
});
/**
* @swagger
@@ -498,22 +500,22 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
metadata: z.record(z.string(), z.string()).optional(),
emailVerified: z.boolean().optional(),
})
.safeParse(req.body)
.safeParse(req.body);
if (error) {
res
.status(400)
.json({ error: error.issues[0]?.message || "Invalid input data" })
return
.json({ error: error.issues[0]?.message || "Invalid input data" });
return;
}
const { email, name, lists, metadata: newMetadata, emailVerified } = body
const { email, name, lists, metadata: newMetadata, emailVerified } = body;
const id = req.params.id
const id = req.params.id;
if (typeof id !== "string") {
res.status(400).json({ error: "Invalid id" })
return
res.status(400).json({ error: "Invalid id" });
return;
}
const subscriber = await prisma.subscriber.findFirst({
@@ -529,10 +531,10 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
},
Metadata: true,
},
})
});
if (!subscriber) {
res.status(404).json({ error: "Subscriber not found" })
return
res.status(404).json({ error: "Subscriber not found" });
return;
}
if (lists?.length) {
@@ -543,15 +545,15 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
},
organizationId: req.organization.id,
},
})
});
if (existingLists.length !== lists.length) {
const foundListIds = existingLists.map((list) => list.id)
const missingListId = lists.find((id) => !foundListIds.includes(id))
const foundListIds = existingLists.map((list) => list.id);
const missingListId = lists.find((id) => !foundListIds.includes(id));
res
.status(400)
.json({ error: `List with id ${missingListId} not found` })
return
.json({ error: `List with id ${missingListId} not found` });
return;
}
}
@@ -591,7 +593,7 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
},
Metadata: true,
},
})
});
const data = {
id: updatedSubscriber.id,
@@ -604,21 +606,21 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
})),
metadata: updatedSubscriber.Metadata.reduce(
(acc, meta) => {
acc[meta.key] = meta.value
return acc
acc[meta.key] = meta.value;
return acc;
},
{} as Record<string, string>
{} as Record<string, string>,
),
createdAt: updatedSubscriber.createdAt,
updatedAt: updatedSubscriber.updatedAt,
}
};
res.json(data)
res.json(data);
} catch (error) {
console.error("Error updating subscriber", error)
res.status(500).json({ error: "Server error" })
console.error("Error updating subscriber", error);
res.status(500).json({ error: "Server error" });
}
})
});
/**
* @swagger
@@ -655,11 +657,11 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
*/
apiRouter.delete("/subscribers/:id", async (req, res) => {
try {
const { id } = req.params
const { id } = req.params;
if (typeof id !== "string") {
res.status(400).json({ error: "Invalid id" })
return
res.status(400).json({ error: "Invalid id" });
return;
}
const subscriber = await prisma.subscriber.findFirst({
@@ -667,25 +669,25 @@ apiRouter.delete("/subscribers/:id", async (req, res) => {
id,
organizationId: req.organization.id,
},
})
});
if (!subscriber) {
res.status(404).json({ error: "Subscriber not found" })
return
res.status(404).json({ error: "Subscriber not found" });
return;
}
await prisma.subscriber.delete({
where: {
id,
},
})
});
res.json({ success: true })
res.json({ success: true });
} catch (error) {
console.error("Error deleting subscriber", error)
res.status(500).json({ error: "Server error" })
console.error("Error deleting subscriber", error);
res.status(500).json({ error: "Server error" });
}
})
});
/**
* @swagger
@@ -719,11 +721,11 @@ apiRouter.delete("/subscribers/:id", async (req, res) => {
*/
apiRouter.get("/subscribers/:id", async (req, res) => {
try {
const { id } = req.params
const { id } = req.params;
if (typeof id !== "string") {
res.status(400).json({ error: "Invalid id" })
return
res.status(400).json({ error: "Invalid id" });
return;
}
const subscriber = await prisma.subscriber.findFirst({
@@ -740,10 +742,10 @@ apiRouter.get("/subscribers/:id", async (req, res) => {
Metadata: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
})
});
if (!subscriber) {
res.status(404).json({ error: "Subscriber not found" })
return
res.status(404).json({ error: "Subscriber not found" });
return;
}
const subscriberData = {
@@ -757,21 +759,21 @@ apiRouter.get("/subscribers/:id", async (req, res) => {
})),
metadata: subscriber.Metadata.reduce(
(acc, meta) => {
acc[meta.key] = meta.value
return acc
acc[meta.key] = meta.value;
return acc;
},
{} as Record<string, string>
{} as Record<string, string>,
),
createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt,
}
};
res.json(subscriberData)
res.json(subscriberData);
} catch (error) {
console.error("Error fetching subscriber", error)
res.status(500).json({ error: "Server error" })
console.error("Error fetching subscriber", error);
res.status(500).json({ error: "Server error" });
}
})
});
/**
* @swagger
@@ -852,30 +854,30 @@ apiRouter.get("/subscribers", async (req, res) => {
emailEquals: z.string().optional(),
nameEquals: z.string().optional(),
})
.safeParse(req.query)
.safeParse(req.query);
if (error) {
res
.status(400)
.json({ error: error.issues[0]?.message || "Invalid input data" })
return
.json({ error: error.issues[0]?.message || "Invalid input data" });
return;
}
const { page, perPage, emailEquals, nameEquals } = query
const { page, perPage, emailEquals, nameEquals } = query;
const where: Prisma.SubscriberWhereInput = {
organizationId: req.organization.id,
}
};
if (emailEquals) {
where.email = { equals: emailEquals }
where.email = { equals: emailEquals };
}
if (nameEquals) {
where.name = { equals: nameEquals }
where.name = { equals: nameEquals };
}
const total = await prisma.subscriber.count({ where })
const total = await prisma.subscriber.count({ where });
const subscribers = await prisma.subscriber.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
@@ -889,9 +891,9 @@ apiRouter.get("/subscribers", async (req, res) => {
},
Metadata: true,
},
})
});
const totalPages = Math.ceil(total / perPage)
const totalPages = Math.ceil(total / perPage);
const subscribersFormatted = subscribers.map((subscriber) => ({
id: subscriber.id,
@@ -904,14 +906,14 @@ apiRouter.get("/subscribers", async (req, res) => {
})),
metadata: subscriber.Metadata.reduce(
(acc, meta) => {
acc[meta.key] = meta.value
return acc
acc[meta.key] = meta.value;
return acc;
},
{} as Record<string, string>
{} as Record<string, string>,
),
createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt,
}))
}));
res.json({
data: subscribersFormatted,
@@ -922,9 +924,9 @@ apiRouter.get("/subscribers", async (req, res) => {
totalPages,
hasMore: page < totalPages,
},
})
});
} catch (error) {
console.error("Error fetching subscribers", error)
res.status(500).json({ error: "Server error" })
console.error("Error fetching subscribers", error);
res.status(500).json({ error: "Server error" });
}
})
});

View File

@@ -1,24 +1,24 @@
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,
@@ -31,36 +31,36 @@ const appRouter = router({
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())
}),
);
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
const { id } = req.params;
const subscriberId = req.query.sid;
const trackedLink = await prisma.trackedLink.findUnique({
where: { id },
})
});
if (!trackedLink) {
res.status(404).send("Link not found")
return
res.status(404).send("Link not found");
return;
}
res.redirect(trackedLink.url)
res.redirect(trackedLink.url);
if (subscriberId && typeof subscriberId === "string") {
await prisma
@@ -71,9 +71,9 @@ app.get("/t/:id", async (req, res) => {
subscriberId,
trackedLinkId: trackedLink.id,
},
})
});
if (!trackedLink.campaignId) return
if (!trackedLink.campaignId) return;
const message = await tx.message.findFirst({
where: {
@@ -83,9 +83,9 @@ app.get("/t/:id", async (req, res) => {
not: "CLICKED",
},
},
})
});
if (!message) return
if (!message) return;
await tx.message.update({
where: {
@@ -94,27 +94,27 @@ app.get("/t/:id", async (req, res) => {
data: {
status: "CLICKED",
},
})
});
})
.catch((error) => {
console.error("Error updating message status", error)
})
console.error("Error updating message status", error);
});
}
} catch (error) {
res.status(404).send("Link not found")
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)
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) => {
@@ -125,41 +125,41 @@ app.get("/img/:id/img.png", async (req, res) => {
openTracking: true,
},
},
})
});
if (!message) {
return
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)
console.error("Error updating message status", error);
}
})
});
app.use("/api", apiRouter)
app.use("/api", apiRouter);
app.use(
"/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"));
});

View File

@@ -1,15 +1,15 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import pMap from "p-map"
import { Mailer } from "../lib/Mailer"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import pMap from "p-map";
import { Mailer } from "../lib/Mailer";
const createCampaignSchema = z.object({
title: z.string().min(1, "Campaign title is required"),
description: z.string().optional(),
organizationId: z.string(),
})
});
export const createCampaign = authProcedure
.input(createCampaignSchema)
@@ -19,13 +19,13 @@ export const createCampaign = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const campaign = await prisma.campaign.create({
@@ -43,10 +43,10 @@ export const createCampaign = authProcedure
},
},
},
})
});
return { campaign }
})
return { campaign };
});
const updateCampaignSchema = z.object({
id: z.string(),
@@ -59,7 +59,7 @@ const updateCampaignSchema = z.object({
scheduledAt: z.date().optional().nullable(),
content: z.string().optional().nullable(),
openTracking: z.boolean().optional(),
})
});
export const updateCampaign = authProcedure
.input(updateCampaignSchema)
@@ -69,13 +69,13 @@ export const updateCampaign = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const campaign = await prisma.campaign.findFirst({
@@ -83,20 +83,20 @@ export const updateCampaign = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
});
}
if (campaign.status !== "DRAFT") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign can not be updated!",
})
});
}
// If a templateId is provided, ensure it exists
@@ -106,13 +106,13 @@ export const updateCampaign = authProcedure
id: input.templateId,
organizationId: input.organizationId,
},
})
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
})
});
}
}
@@ -122,13 +122,13 @@ export const updateCampaign = authProcedure
id: { in: input.listIds },
organizationId: input.organizationId,
},
})
});
if (lists.length !== input.listIds.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "One or more lists not found",
})
});
}
}
@@ -157,17 +157,17 @@ export const updateCampaign = authProcedure
},
},
},
})
});
return { campaign: updatedCampaign }
})
return { campaign: updatedCampaign };
});
export const deleteCampaign = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -175,13 +175,13 @@ export const deleteCampaign = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const campaign = await prisma.campaign.findFirst({
@@ -189,29 +189,29 @@ export const deleteCampaign = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
});
}
// On Delete: Cascade delete all messages
await prisma.campaign.delete({
where: { id: input.id },
})
});
return { success: true }
})
return { success: true };
});
export const startCampaign = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -219,13 +219,13 @@ export const startCampaign = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const [smtpSettings, emailSettings] = await Promise.all([
@@ -239,14 +239,14 @@ export const startCampaign = authProcedure
organizationId: input.organizationId,
},
}),
])
]);
if (!smtpSettings) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"You must configure your SMTP settings before running a campaign",
})
});
}
if (!emailSettings) {
@@ -254,7 +254,7 @@ export const startCampaign = authProcedure
code: "BAD_REQUEST",
message:
"You must configure your email delivery settings before running a campaign",
})
});
}
const campaign = await prisma.campaign.findFirst({
@@ -288,13 +288,13 @@ export const startCampaign = authProcedure
},
},
},
})
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
});
}
// Check campaign status
@@ -302,14 +302,14 @@ export const startCampaign = authProcedure
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign can only be started from DRAFT status",
})
});
}
if (!campaign.subject) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email Subject is required",
})
});
}
// Check campaign has lists
@@ -317,7 +317,7 @@ export const startCampaign = authProcedure
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign must have at least one list",
})
});
}
if (!campaign.content) {
@@ -325,75 +325,78 @@ export const startCampaign = authProcedure
code: "BAD_REQUEST",
message:
"Can not send an empty campaign. Write some content in the editor to start sending.",
})
});
}
type Subscriber =
(typeof campaign)["CampaignLists"][0]["List"]["ListSubscribers"][0]["Subscriber"] & {
Metadata: { key: string; value: string }[]
}
Metadata: { key: string; value: string }[];
};
const subscribers = new Map<string, Subscriber>()
const subscribers = new Map<string, Subscriber>();
await pMap(campaign.CampaignLists, (campaignList) => {
return pMap(campaignList.List.ListSubscribers, (listSubscriber) => {
subscribers.set(listSubscriber.Subscriber.id, listSubscriber.Subscriber)
})
})
subscribers.set(
listSubscriber.Subscriber.id,
listSubscriber.Subscriber,
);
});
});
if (subscribers.size === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign must have at least one recipient",
})
});
}
const organization = await prisma.organization.findUnique({
where: { id: input.organizationId },
select: { name: true },
})
});
if (!organization) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Organization details could not be retrieved.",
})
});
}
const generalSettings = await prisma.generalSettings.findFirst({
where: {
organizationId: input.organizationId,
},
})
});
if (!generalSettings?.baseURL) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Base URL must be configured in settings before running a campaign",
})
});
}
const status =
campaign.scheduledAt && campaign.scheduledAt > new Date()
? "SCHEDULED"
: "CREATING"
: "CREATING";
const updatedCampaign = await prisma.campaign.update({
where: { id: campaign.id },
data: {
status,
},
})
});
return { campaign: updatedCampaign }
})
return { campaign: updatedCampaign };
});
export const cancelCampaign = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
}),
)
.mutation(async ({ input }) => {
const campaign = await prisma.campaign.findFirst({
@@ -401,20 +404,20 @@ export const cancelCampaign = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
});
}
if (!["CREATING", "SENDING", "SCHEDULED"].includes(campaign.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign cannot be cancelled",
})
});
}
await prisma.$transaction([
@@ -437,10 +440,10 @@ export const cancelCampaign = authProcedure
status: "CANCELLED",
},
}),
])
]);
return { success: true }
})
return { success: true };
});
export const sendTestEmail = authProcedure
.input(
@@ -448,7 +451,7 @@ export const sendTestEmail = authProcedure
campaignId: z.string(),
organizationId: z.string(),
email: z.string().email(),
})
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -456,27 +459,27 @@ export const sendTestEmail = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const settings = await prisma.smtpSettings.findFirst({
where: {
organizationId: input.organizationId,
},
})
});
if (!settings) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"You must configure your SMTP settings before sending test emails",
})
});
}
const campaign = await prisma.campaign.findFirst({
@@ -487,56 +490,56 @@ export const sendTestEmail = authProcedure
include: {
Template: true,
},
})
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
});
}
if (!campaign.content) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Campaign must have content",
})
});
}
if (!campaign.subject) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email Subject is required",
})
});
}
const content = campaign.Template
? campaign.Template.content.replace(/{{content}}/g, campaign.content)
: campaign.content
: campaign.content;
const mailer = new Mailer(settings)
const mailer = new Mailer(settings);
const result = await mailer.sendEmail({
to: input.email,
subject: `[Test] ${campaign.subject}`,
html: content,
from: `${settings.fromName} <${settings.fromEmail}>`,
})
});
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send test email",
})
});
}
return { success: true }
})
return { success: true };
});
const duplicateCampaignSchema = z.object({
id: z.string(),
organizationId: z.string(),
})
});
export const duplicateCampaign = authProcedure
.input(duplicateCampaignSchema)
@@ -546,13 +549,13 @@ export const duplicateCampaign = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const originalCampaign = await prisma.campaign.findFirst({
@@ -568,13 +571,13 @@ export const duplicateCampaign = authProcedure
},
},
},
})
});
if (!originalCampaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
});
}
const newCampaign = await prisma.campaign.create({
@@ -601,7 +604,7 @@ export const duplicateCampaign = authProcedure
},
},
},
})
});
return { campaign: newCampaign }
})
return { campaign: newCampaign };
});

View File

@@ -1,10 +1,10 @@
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))
@@ -14,13 +14,13 @@ export const listCampaigns = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const where: Prisma.CampaignWhereInput = {
@@ -34,7 +34,7 @@ export const listCampaigns = authProcedure
],
}
: {}),
}
};
const [total, campaigns] = await prisma.$transaction([
prisma.campaign.count({ where }),
@@ -67,9 +67,9 @@ export const listCampaigns = authProcedure
},
},
}),
])
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
campaigns,
@@ -80,15 +80,15 @@ export const listCampaigns = authProcedure
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({
@@ -96,13 +96,13 @@ export const getCampaign = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const campaign = await prisma.campaign.findFirst({
@@ -118,13 +118,13 @@ export const getCampaign = authProcedure
},
},
},
})
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
});
}
const listSubscribers = await prisma.listSubscriber.findMany({
@@ -138,7 +138,7 @@ export const getCampaign = authProcedure
id: true,
},
distinct: ["subscriberId"],
})
});
// Add the count to each list for backward compatibility
const campaignWithCounts = {
@@ -150,7 +150,7 @@ export const getCampaign = authProcedure
listId: cl.listId,
unsubscribedAt: null,
},
})
});
return {
...cl,
@@ -160,12 +160,12 @@ export const getCampaign = authProcedure
ListSubscribers: count,
},
},
}
})
};
}),
),
// Add the unique subscriber count directly to the campaign object
uniqueRecipientCount: listSubscribers.length,
}
};
const promises = {
totalMessages: prisma.message.count({
@@ -221,9 +221,9 @@ export const getCampaign = authProcedure
},
},
}),
}
};
const result = await resolveProps(promises)
const result = await resolveProps(promises);
return {
campaign: campaignWithCounts,
@@ -245,5 +245,5 @@ export const getCampaign = authProcedure
? (result.opened / result.sentMessages) * 100
: 0,
},
}
})
};
});

View File

@@ -1,4 +1,4 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
createCampaign,
updateCampaign,
@@ -7,8 +7,8 @@ import {
cancelCampaign,
sendTestEmail,
duplicateCampaign,
} from "./mutation"
import { getCampaign, listCampaigns } from "./query"
} from "./mutation";
import { getCampaign, listCampaigns } from "./query";
export const campaignRouter = router({
create: createCampaign,
@@ -20,4 +20,4 @@ export const campaignRouter = router({
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)
.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,
}
};
const dailyMaintenanceJob: CronJob = {
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,
}
};
const cronJobs: CronJob[] = [
sendMessagesJob,
dailyMaintenanceJob,
processQueuedCampaignsJob,
]
];
export const initializeCronJobs = () => {
const scheduledJobs = cronJobs
.filter((job) => job.enabled)
.map((job) => {
const task = cron.schedule(job.schedule, job.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 }
})
`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()),
}
}
};
};

View File

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

View File

@@ -1,21 +1,21 @@
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,
},
})
});
let totalDeletedMessages = 0
let totalDeletedMessages = 0;
for (const org of organizations) {
const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30
const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30;
const cleanupOlderThanDate = dayjs()
.subtract(cleanupIntervalDays, "days")
.toDate()
.toDate();
try {
const messagesToClean = await prisma.message.findMany({
@@ -33,7 +33,7 @@ export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
select: {
id: true,
},
})
});
await prisma.message.updateMany({
data: {
@@ -44,25 +44,25 @@ export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
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
`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
console.error(`Error deleting messages for org ${org.id}: ${error}`);
continue;
}
}
if (totalDeletedMessages > 0) {
console.log(
`Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.`
)
`Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.`,
);
} else {
console.log("Daily maintenance job finished. No messages to delete.")
console.log("Daily maintenance job finished. No messages to delete.");
}
})
});

View File

@@ -1,23 +1,23 @@
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"
} 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[]
selectedListIds: string[],
): Promise<Map<string, Subscriber & { Metadata: SubscriberMetadata[] }>> {
if (selectedListIds.length === 0) {
return new Map()
return new Map();
}
const subscribers = await prisma.subscriber.findMany({
@@ -34,19 +34,19 @@ async function getSubscribersForCampaign(
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)
})
subscribersMap.set(subscriber.id, subscriber);
});
return subscribersMap
return subscribersMap;
}
const logged = {
@@ -56,18 +56,18 @@ const logged = {
missingCampaignContent: false,
missingCampaignSubject: false,
errorProcessingCampaign: false,
}
};
const oneTimeLogger = (key: keyof typeof logged, ...messages: unknown[]) => {
if (!logged[key]) {
console.log(...messages)
logged[key] = true
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",
@@ -88,17 +88,17 @@ export const processQueuedCampaigns = cronJob(
},
Template: true,
},
})
});
if (queuedCampaigns.length === 0) {
oneTimeLogger(
"noQueuedCampaigns",
"Cron job: No queued campaigns to process."
)
return
"Cron job: No queued campaigns to process.",
);
return;
}
turnOnLogger("noQueuedCampaigns")
turnOnLogger("noQueuedCampaigns");
for (const campaign of queuedCampaigns) {
try {
@@ -111,82 +111,82 @@ export const processQueuedCampaigns = cronJob(
) {
oneTimeLogger(
"missingCampaignData",
`Cron job: Campaign ${campaign.id} is missing required data (content, subject, organization, or baseURL). Skipping.`
)
`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
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
)
selectedListIds,
);
if (allSubscribersMap.size === 0) {
oneTimeLogger(
"noSubscribers",
`Cron job: Campaign ${campaign.id} has no subscribers. Skipping.`
)
continue
`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)
).map((m) => m.subscriberId);
const subscribersWithMessage = new Set(messageSubscriberIds);
const subscribersToProcess = Array.from(
allSubscribersMap.values()
).filter((sub) => !subscribersWithMessage.has(sub.id))
allSubscribersMap.values(),
).filter((sub) => !subscribersWithMessage.has(sub.id));
if (subscribersToProcess.length === 0) {
continue
continue;
}
await prisma.$transaction(
async (tx) => {
const linkTracker = new LinkTracker(tx)
const messagesToCreate: Prisma.MessageCreateManyInput[] = []
const linkTracker = new LinkTracker(tx);
const messagesToCreate: Prisma.MessageCreateManyInput[] = [];
for (const subscriber of subscribersToProcess) {
const messageId = uuidV4()
const messageId = uuidV4();
if (!campaign.content) {
oneTimeLogger(
"missingCampaignContent",
`Cron job: Campaign ${campaign.id} has no content. Skipping.`
)
continue
`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,
)
: campaign.content
: campaign.content;
if (!campaign.subject) {
oneTimeLogger(
"missingCampaignSubject",
`Cron job: Campaign ${campaign.id} has no subject. Skipping.`
)
continue
`Cron job: Campaign ${campaign.id} has no subject. Skipping.`,
);
continue;
}
turnOnLogger("missingCampaignSubject")
turnOnLogger("missingCampaignSubject");
const placeholderData: Partial<
Record<PlaceholderDataKey, string>
@@ -197,37 +197,37 @@ export const processQueuedCampaigns = cronJob(
"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" />`
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
placeholderData["subscriber.name"] = subscriber.name;
}
if (subscriber.Metadata) {
for (const meta of subscriber.Metadata) {
placeholderData[`subscriber.metadata.${meta.key}`] =
meta.value
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
`Cron job: Campaign ${campaign.id} has no baseURL. Skipping.`,
);
continue;
}
const { content: finalContent } =
await linkTracker.replaceMessageContentWithTrackedLinks(
emailContent,
campaign.id,
generalSettings.baseURL
)
generalSettings.baseURL,
);
messagesToCreate.push({
id: messageId,
@@ -235,13 +235,13 @@ export const processQueuedCampaigns = cronJob(
subscriberId: subscriber.id,
content: finalContent,
status: "QUEUED",
})
});
}
if (messagesToCreate.length > 0) {
await tx.message.createMany({
data: messagesToCreate,
})
});
const subscribersLeft = await tx.subscriber.count({
where: {
@@ -253,33 +253,33 @@ export const processQueuedCampaigns = cronJob(
},
},
},
})
});
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}.`
)
`Cron job: Created ${messagesToCreate.length} messages for campaign ${campaign.id}.`,
);
}
},
{ timeout: 60_000 }
) // End transaction
{ timeout: 60_000 },
); // End transaction
turnOnLogger("errorProcessingCampaign")
turnOnLogger("errorProcessingCampaign");
} catch (error) {
oneTimeLogger(
"errorProcessingCampaign",
`Cron job: Error processing campaign ${campaign.id}:`,
error
)
error,
);
// Optionally, mark campaign as FAILED
// await prisma.campaign.update({ where: { id: basicCampaignInfo.id }, data: { status: 'FAILED', statusReason: error.message }});
}
}
}
)
},
);

View File

@@ -1,13 +1,13 @@
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([
@@ -20,16 +20,16 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
prisma.generalSettings.findFirst({
where: { organizationId: organization.id },
}),
])
]);
if (!smtpSettings || !emailSettings) {
logger.warn(
`Required settings not found for org ${organization.id}, skipping`
)
continue
`Required settings not found for org ${organization.id}, skipping`,
);
continue;
}
const windowStart = subSeconds(new Date(), emailSettings.rateWindow)
const windowStart = subSeconds(new Date(), emailSettings.rateWindow);
const sentInWindow = await prisma.message.count({
where: {
status: {
@@ -42,12 +42,12 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
organizationId: organization.id,
},
},
})
});
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow)
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow);
if (availableSlots === 0) {
continue
continue;
}
// Message status is now independent of campaign status.
@@ -81,7 +81,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
},
},
take: availableSlots,
})
});
const noMoreRetryingMessages = await prisma.message.count({
where: {
@@ -90,7 +90,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
organizationId: organization.id,
},
},
})
});
if (!messages.length && noMoreRetryingMessages === 0) {
await prisma.campaign.updateMany({
@@ -109,39 +109,39 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
status: "COMPLETED",
completedAt: new Date(),
},
})
continue
});
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 fromName =
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? ""
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? "";
const fromEmail =
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? ""
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? "";
if (!fromName || !fromEmail) {
logger.warn("No from name or email found, message will not be sent")
continue
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
logger.warn("No subject found for campaign");
return;
}
await prisma.message.update({
where: { id: message.id },
data: { status: "PENDING" },
})
});
try {
const result = await mailer.sendEmail({
@@ -149,7 +149,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
subject: message.Campaign.subject,
html: message.content,
from: `${fromName} <${fromEmail}>`,
})
});
await prisma.message.update({
where: { id: message.id },
@@ -166,7 +166,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
},
lastTriedAt: new Date(),
},
})
});
} catch (error) {
await prisma.message.update({
where: { id: message.id },
@@ -181,10 +181,10 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
},
lastTriedAt: new Date(),
},
})
});
}
},
{ concurrency: emailSettings.concurrency }
)
{ concurrency: emailSettings.concurrency },
);
}
})
});

View File

@@ -1,17 +1,17 @@
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({
@@ -19,17 +19,17 @@ export const getDashboardStats = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
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
@@ -40,7 +40,7 @@ export const getDashboardStats = authProcedure
},
}
: {}),
}
};
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
await Promise.all([
@@ -76,20 +76,20 @@ export const getDashboardStats = authProcedure
// Subscriber growth over time
prisma.$queryRawTyped(
subscriberGrowthQuery(input.organizationId, from, to)
subscriberGrowthQuery(input.organizationId, from, to),
),
prisma.$queryRawTyped(countDbSize(input.organizationId)),
])
]);
// Process message stats
const messageStatsByStatus = messageStats.reduce(
(acc, stat) => {
acc[stat.status as MessageStatus] = stat._count
return acc
acc[stat.status as MessageStatus] = stat._count;
return acc;
},
{} as Record<MessageStatus, number>
)
{} as Record<MessageStatus, number>,
);
// Process recent campaigns
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
@@ -105,7 +105,7 @@ export const getDashboardStats = authProcedure
prisma.message.count({
where: { campaignId: campaign.id },
}),
])
]);
return {
id: campaign.id,
@@ -116,24 +116,24 @@ export const getDashboardStats = authProcedure
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]
const point = subscriberGrowth[i];
if (!point?.date) {
continue
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),
})
});
}
return {
@@ -141,5 +141,5 @@ export const getDashboardStats = authProcedure
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,
})
});

View File

@@ -1,17 +1,17 @@
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({
@@ -26,19 +26,19 @@ prisma.$connect().then(async () => {
data: {
status: "CANCELLED",
},
})
});
app.listen(PORT, () => {
console.log(`Server is running on port ${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,19 +1,19 @@
import { prisma } from "../utils/prisma"
import { prisma } from "../utils/prisma";
type TransactionClient = Parameters<
Parameters<typeof prisma.$transaction>[0]
>[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
this.tx = tx;
}
private async getOrCreateTrackLink(url: string, campaignId: string) {
const originalUrl = url.replace(this.trackSuffix, "")
const originalUrl = url.replace(this.trackSuffix, "");
try {
const trackedLink = await this.tx.trackedLink.upsert({
@@ -28,9 +28,9 @@ export class LinkTracker {
campaignId,
},
update: {},
})
});
return trackedLink
return trackedLink;
} catch (error) {
// In case of race condition, try to fetch the existing record
return await this.tx.trackedLink.findFirstOrThrow({
@@ -38,65 +38,65 @@ export class LinkTracker {
url: originalUrl,
campaignId,
},
})
});
}
}
private findTrackingLinks(content: string) {
const regex = /https?:\/\/[^\s<>"']+@TRACK/g
const matches = content.match(regex)
const regex = /https?:\/\/[^\s<>"']+@TRACK/g;
const matches = content.match(regex);
if (!matches) {
return []
return [];
}
return matches
return matches;
}
async findTrackingLinksAndCreate({
content,
campaignId,
}: {
content: string
campaignId: string
content: string;
campaignId: string;
}) {
const links = this.findTrackingLinks(content)
const links = this.findTrackingLinks(content);
const trackingLinks = await Promise.all(
links.map((link) => this.getOrCreateTrackLink(link, campaignId))
)
links.map((link) => this.getOrCreateTrackLink(link, campaignId)),
);
return trackingLinks
return trackingLinks;
}
async replaceMessageContentWithTrackedLinks(
content: string,
campaignId: string,
baseURL: string
baseURL: string,
) {
const links = this.findTrackingLinks(content)
let updatedContent = content
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 trackedLink = await this.getOrCreateTrackLink(link, campaignId);
const trackingUrl = `${baseURL}/r/${trackedLink.id}`;
return {
originalLink: link,
trackedLinkId: trackedLink.id,
trackingUrl,
}
})
)
};
}),
);
trackedLinkResults.forEach(({ originalLink, trackingUrl }) => {
updatedContent = updatedContent.replace(originalLink, trackingUrl)
})
updatedContent = updatedContent.replace(originalLink, trackingUrl);
});
return {
content: updatedContent,
trackedIds: trackedLinkResults.map(({ trackedLinkId }) => trackedLinkId),
}
};
}
}

View File

@@ -1,42 +1,42 @@
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 = {
@@ -47,7 +47,7 @@ export class Mailer {
user: smtpSettings.username,
pass: smtpSettings.password,
},
}
};
if (smtpSettings.encryption === "STARTTLS") {
transportOptions = {
@@ -55,13 +55,13 @@ export class Mailer {
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 = {
@@ -70,10 +70,10 @@ export class Mailer {
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> {
@@ -84,20 +84,20 @@ export class Mailer {
// TODO: Handle plain text
text: options.text || undefined,
html: options.html || undefined,
})
});
let response: SendEmailResponse = {
success: false,
messageId: result.messageId,
from: options.from,
}
};
if (result.accepted.length > 0) {
response.success = true
response.success = true;
} else if (result.rejected.length > 0) {
response.success = false
response.success = false;
}
return response
return response;
}
}

View File

@@ -1,13 +1,13 @@
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(),
})
});
export const createList = authProcedure
.input(createListSchema)
@@ -17,13 +17,13 @@ export const createList = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
})
});
}
const list = await prisma.list.create({
@@ -38,18 +38,18 @@ export const createList = authProcedure
description: true,
createdAt: true,
},
})
});
return {
list,
}
})
};
});
const updateListSchema = z.object({
id: z.string(),
name: z.string().min(1, "List name is required"),
description: z.string().optional(),
})
});
export const updateList = authProcedure
.input(updateListSchema)
@@ -61,13 +61,13 @@ export const updateList = authProcedure
include: {
Organization: true,
},
})
});
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
})
});
}
// Verify user has access to organization
@@ -76,13 +76,13 @@ export const updateList = authProcedure
userId: ctx.user.id,
organizationId: list.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
})
});
}
const updatedList = await prisma.list.update({
@@ -100,12 +100,12 @@ export const updateList = authProcedure
createdAt: true,
updatedAt: true,
},
})
});
return {
list: updatedList,
}
})
};
});
export const deleteList = authProcedure
.input(z.object({ id: z.string() }))
@@ -117,13 +117,13 @@ export const deleteList = authProcedure
include: {
Organization: true,
},
})
});
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
})
});
}
// Verify user has access to organization
@@ -132,20 +132,20 @@ export const deleteList = authProcedure
userId: ctx.user.id,
organizationId: list.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
})
});
}
await prisma.list.delete({
where: {
id: input.id,
},
})
});
return { success: true }
})
return { success: true };
});

View File

@@ -1,9 +1,9 @@
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(
@@ -11,7 +11,7 @@ export const getLists = authProcedure
.object({
organizationId: z.string(),
})
.merge(paginationSchema)
.merge(paginationSchema),
)
.query(async ({ ctx, input }) => {
// Verify user has access to organization
@@ -20,13 +20,13 @@ export const getLists = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
})
});
}
const where: Prisma.ListWhereInput = {
@@ -39,7 +39,7 @@ export const getLists = authProcedure
],
}
: {}),
}
};
const [total, lists] = await Promise.all([
prisma.list.count({ where }),
@@ -60,9 +60,9 @@ export const getLists = authProcedure
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,
@@ -73,14 +73,14 @@ export const getLists = authProcedure
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({
@@ -95,13 +95,13 @@ export const getList = authProcedure
},
},
},
})
});
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
})
});
}
// Verify user has access to organization
@@ -110,14 +110,14 @@ export const getList = authProcedure
userId: ctx.user.id,
organizationId: list.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
})
});
}
return list
})
return list;
});

View File

@@ -1,6 +1,6 @@
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,
@@ -8,4 +8,4 @@ export const listRouter = router({
delete: deleteList,
get: getList,
list: getLists,
})
});

View File

@@ -1,15 +1,15 @@
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({
@@ -17,13 +17,13 @@ export const resendMessage = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization.",
})
});
}
const message = await prisma.message.findFirst({
@@ -33,13 +33,13 @@ export const resendMessage = authProcedure
organizationId: input.organizationId,
},
},
})
});
if (!message) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found or you don't have access.",
})
});
}
const updatedMessage = await prisma.message.update({
@@ -53,7 +53,7 @@ export const resendMessage = authProcedure
error: null,
messageId: null,
},
})
});
return updatedMessage
})
return updatedMessage;
});

View File

@@ -1,9 +1,9 @@
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",
@@ -13,7 +13,7 @@ const messageStatusEnum = z.enum([
"CLICKED",
"FAILED",
"RETRYING",
])
]);
export const listMessages = authProcedure
.input(
@@ -24,7 +24,7 @@ export const listMessages = authProcedure
subscriberId: z.string().optional(),
status: messageStatusEnum.optional(),
})
.merge(paginationSchema)
.merge(paginationSchema),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -32,13 +32,13 @@ export const listMessages = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const where: Prisma.MessageWhereInput = {
@@ -78,7 +78,7 @@ export const listMessages = authProcedure
],
}
: {}),
}
};
const [total, messages] = await Promise.all([
prisma.message.count({ where }),
@@ -103,9 +103,9 @@ export const listMessages = authProcedure
},
},
}),
])
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
messages,
@@ -116,14 +116,14 @@ export const listMessages = authProcedure
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({
@@ -146,14 +146,14 @@ export const getMessage = authProcedure
},
},
},
})
});
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,
})
});

View File

@@ -1,10 +1,10 @@
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({
@@ -20,12 +20,12 @@ export async function findMessagesByStatus({
},
status: Array.isArray(status) ? { in: status } : status,
},
})
});
}
interface CampaignMessagesQueryOptions {
campaignId: string
organizationId: string
campaignId: string;
organizationId: string;
}
export async function getDeliveredMessages({
@@ -36,7 +36,7 @@ export async function getDeliveredMessages({
campaignId,
organizationId,
status: ["SENT", "CLICKED", "OPENED"],
})
});
}
export async function getFailedMessages({
@@ -47,7 +47,7 @@ export async function getFailedMessages({
campaignId,
organizationId,
status: "FAILED",
})
});
}
export async function getOpenedMessages({
@@ -58,7 +58,7 @@ export async function getOpenedMessages({
campaignId,
organizationId,
status: ["OPENED", "CLICKED"], // Clicked implies opened
})
});
}
export async function getClickedMessages({
@@ -69,7 +69,7 @@ export async function getClickedMessages({
campaignId,
organizationId,
status: "CLICKED",
})
});
}
export async function getQueuedMessages({
@@ -80,5 +80,5 @@ export async function getQueuedMessages({
campaignId,
organizationId,
status: "QUEUED",
})
});
}

View File

@@ -1,13 +1,13 @@
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(),
})
});
export const createOrganization = authProcedure
.input(createOrganizationSchema)
@@ -28,7 +28,7 @@ export const createOrganization = authProcedure
name: "Newsletter",
content: await fs.readFile(
"templates/newsletter.html",
"utf-8"
"utf-8",
),
},
],
@@ -49,18 +49,18 @@ export const createOrganization = authProcedure
description: true,
createdAt: true,
},
})
});
return {
organization,
}
})
};
});
const updateOrganizationSchema = z.object({
id: z.string(),
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
})
});
export const updateOrganization = authProcedure
.input(updateOrganizationSchema)
@@ -70,13 +70,13 @@ export const updateOrganization = authProcedure
userId: ctx.user.id,
organizationId: input.id,
},
})
});
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to update this organization.",
})
});
}
const updatedOrganization = await prisma.organization.update({
@@ -92,7 +92,7 @@ export const updateOrganization = authProcedure
createdAt: true,
updatedAt: true,
},
})
});
return { organization: updatedOrganization }
})
return { organization: updatedOrganization };
});

View File

@@ -1,13 +1,13 @@
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({
@@ -15,13 +15,13 @@ export const getOrganizationById = authProcedure
userId: ctx.user.id,
organizationId: input.id,
},
})
});
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization.",
})
});
}
const organization = await prisma.organization.findUnique({
@@ -33,14 +33,14 @@ export const getOrganizationById = authProcedure
createdAt: true,
updatedAt: true,
},
})
});
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,
})
});

View File

@@ -1,9 +1,9 @@
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(),
@@ -15,7 +15,7 @@ const smtpSchema = z.object({
fromName: z.string().optional(),
secure: z.boolean(),
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
})
});
export const updateSmtp = authProcedure
.input(smtpSchema)
@@ -25,13 +25,13 @@ export const updateSmtp = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const smtpSettings = await prisma.smtpSettings.findFirst({
@@ -45,7 +45,7 @@ export const updateSmtp = authProcedure
},
},
},
})
});
const settings = await prisma.smtpSettings.upsert({
where: {
@@ -72,10 +72,10 @@ export const updateSmtp = authProcedure
secure: input.secure,
encryption: input.encryption,
},
})
});
return { settings }
})
return { settings };
});
const emailDeliverySchema = z.object({
organizationId: z.string(),
@@ -85,7 +85,7 @@ const emailDeliverySchema = z.object({
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)
@@ -95,13 +95,13 @@ export const updateEmailDelivery = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const settings = await prisma.emailDeliverySettings.upsert({
@@ -125,10 +125,10 @@ export const updateEmailDelivery = authProcedure
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
},
})
});
return { settings }
})
return { settings };
});
const generalSettingsSchema = z.object({
organizationId: z.string(),
@@ -136,7 +136,7 @@ const generalSettingsSchema = z.object({
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)
@@ -146,13 +146,13 @@ export const updateGeneral = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const settings = await prisma.generalSettings.upsert({
@@ -172,16 +172,16 @@ export const updateGeneral = authProcedure
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(),
})
});
export const createApiKey = authProcedure
.input(createApiKeySchema)
@@ -191,19 +191,19 @@ export const createApiKey = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
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")}`
key = `sk_${randomBytes(32).toString("hex")}`;
}
const apiKey = await prisma.apiKey.create({
@@ -217,15 +217,15 @@ export const createApiKey = authProcedure
id: true,
key: true,
},
})
});
return apiKey
})
return apiKey;
});
const deleteApiKeySchema = z.object({
organizationId: z.string(),
id: z.string(),
})
});
export const deleteApiKey = authProcedure
.input(deleteApiKeySchema)
@@ -235,13 +235,13 @@ export const deleteApiKey = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
await prisma.apiKey.delete({
@@ -249,10 +249,10 @@ export const deleteApiKey = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
return { success: true }
})
return { success: true };
});
const createWebhookSchema = z.object({
organizationId: z.string(),
@@ -261,7 +261,7 @@ const createWebhookSchema = z.object({
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)
@@ -271,13 +271,13 @@ export const createWebhook = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
// const webhook = await prisma.webhook.create({
@@ -292,15 +292,15 @@ export const createWebhook = authProcedure
// })
// TODO: Implement webhook creation
return { webhook: null }
})
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({
@@ -308,42 +308,42 @@ export const deleteWebhook = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
// TODO: Implement webhook deletion
return { success: true }
})
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,
},
})
});
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 = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
@@ -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}>`,
})
});
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,13 +1,13 @@
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({
@@ -15,13 +15,13 @@ export const getSmtp = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const settings = await prisma.smtpSettings.findFirst({
@@ -35,16 +35,16 @@ export const getSmtp = authProcedure
},
},
},
})
});
return settings
})
return settings;
});
export const getGeneral = authProcedure
.input(
z.object({
organizationId: z.string(),
})
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -52,29 +52,29 @@ export const getGeneral = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
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({
@@ -82,13 +82,13 @@ export const listApiKeys = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const apiKeys = await prisma.apiKey.findMany({
@@ -103,16 +103,16 @@ export const listApiKeys = authProcedure
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({
@@ -120,17 +120,17 @@ export const listWebhooks = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
// TODO: Implement later
return []
return [];
// const webhooks = await prisma.webhook.findMany({
// where: {
// organizationId: input.organizationId,
@@ -141,13 +141,13 @@ export const listWebhooks = authProcedure
// })
// return webhooks
})
});
export const getEmailDelivery = authProcedure
.input(
z.object({
organizationId: z.string(),
})
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -155,20 +155,20 @@ export const getEmailDelivery = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const settings = await prisma.emailDeliverySettings.findUnique({
where: {
organizationId: input.organizationId,
},
})
});
return settings
})
return settings;
});

View File

@@ -1,11 +1,11 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
getSmtp,
getGeneral,
listApiKeys,
listWebhooks,
getEmailDelivery,
} from "./query"
} from "./query";
import {
updateSmtp,
testSmtp,
@@ -15,7 +15,7 @@ import {
createWebhook,
deleteWebhook,
updateEmailDelivery,
} from "./mutation"
} from "./mutation";
export const settingsRouter = router({
getSmtp: getSmtp,
@@ -34,4 +34,4 @@ export const settingsRouter = router({
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")
return dateObj.format("DD MMM YYYY");
}
return dateObj.fromNow()
return dateObj.fromNow();
}

View File

@@ -1,32 +1,32 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { subDays } from "date-fns"
import { TRPCError } from "@trpc/server"
import { resolveProps } from "../utils/pProps"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { subDays } from "date-fns";
import { TRPCError } from "@trpc/server";
import { resolveProps } from "../utils/pProps";
import {
countDistinctRecipients,
countDistinctRecipientsInTimeRange,
} from "../../prisma/client/sql"
import { MessageStatus } from "../../prisma/client"
} from "../../prisma/client/sql";
import { MessageStatus } from "../../prisma/client";
export const getStats = authProcedure
.input(
z.object({
organizationId: z.string(),
})
}),
)
.query(async ({ ctx, input }) => {
const now = new Date()
const thirtyDaysAgo = subDays(now, 30)
const sixtyDaysAgo = subDays(now, 60)
const now = new Date();
const thirtyDaysAgo = subDays(now, 30);
const sixtyDaysAgo = subDays(now, 60);
const processedMessageStatuses: MessageStatus[] = [
"SENT",
"CLICKED",
"OPENED",
"FAILED",
]
];
// Check auth
const hasAccess = await prisma.userOrganization.findFirst({
@@ -34,13 +34,13 @@ export const getStats = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!hasAccess) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization",
})
});
}
// We need to get this first for calculating the other stats
@@ -78,7 +78,7 @@ export const getStats = authProcedure
status: { in: processedMessageStatuses },
},
}),
])
]);
const promises = {
allTimeSubscribers: prisma.subscriber.count({
@@ -109,9 +109,9 @@ export const getStats = authProcedure
lt: now,
},
},
})
});
return openedMessages / (totalMessagesLast30Days || 1)
return openedMessages / (totalMessagesLast30Days || 1);
})(),
openRateLastMonth: (async () => {
const openedMessages = await prisma.message.count({
@@ -127,9 +127,9 @@ export const getStats = authProcedure
lt: thirtyDaysAgo,
},
},
})
});
return openedMessages / (totalMessagesLastPeriod || 1)
return openedMessages / (totalMessagesLastPeriod || 1);
})(),
unsubscribedThisMonth: prisma.listSubscriber.count({
where: {
@@ -218,12 +218,12 @@ export const getStats = authProcedure
lt: now,
},
},
})
});
return {
delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLast30Days || 1),
}
};
})(),
deliveryRateLastMonth: (async () => {
const deliveredMessages = await prisma.message.count({
@@ -239,12 +239,12 @@ export const getStats = authProcedure
lt: thirtyDaysAgo,
},
},
})
});
return {
delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLastPeriod || 1),
}
};
})(),
clickRateThisMonth: (async () => {
const clickedMessages = await prisma.message.count({
@@ -258,12 +258,12 @@ export const getStats = authProcedure
lt: now,
},
},
})
});
return {
clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLast30Days || 1),
}
};
})(),
clickRateLastMonth: (async () => {
const clickedMessages = await prisma.message.count({
@@ -277,33 +277,33 @@ export const getStats = authProcedure
lt: thirtyDaysAgo,
},
},
})
});
return {
clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLastPeriod || 1),
}
};
})(),
recipients: prisma.$queryRawTyped(
countDistinctRecipients(input.organizationId)
countDistinctRecipients(input.organizationId),
),
recipientsThisMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange(
input.organizationId,
thirtyDaysAgo,
now
)
now,
),
),
recipientsLastMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange(
input.organizationId,
sixtyDaysAgo,
thirtyDaysAgo
)
thirtyDaysAgo,
),
}
),
};
const result = await resolveProps(promises)
const result = await resolveProps(promises);
const data = {
campaigns: {
@@ -373,7 +373,7 @@ export const getStats = authProcedure
lastMonth: result.unsubscribedLastMonth,
comparison: result.unsubscribedThisMonth - result.unsubscribedLastMonth,
},
}
};
return data
})
return data;
});

View File

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

View File

@@ -1,9 +1,9 @@
import { z } from "zod"
import { authProcedure, publicProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { parse } from "csv-parse"
import { Readable } from "stream"
import { z } from "zod";
import { authProcedure, publicProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { parse } from "csv-parse";
import { Readable } from "stream";
const createSubscriberSchema = z.object({
email: z.string().email("Invalid email address"),
@@ -16,10 +16,10 @@ const createSubscriberSchema = z.object({
z.object({
key: z.string().min(1).max(64),
value: z.string().min(1).max(256),
})
}),
)
.optional(),
})
});
export const createSubscriber = authProcedure
.input(createSubscriberSchema)
@@ -29,13 +29,13 @@ export const createSubscriber = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const existingSubscriber = await prisma.subscriber.findFirst({
@@ -43,13 +43,13 @@ export const createSubscriber = authProcedure
email: input.email,
organizationId: input.organizationId,
},
})
});
if (existingSubscriber) {
throw new TRPCError({
code: "CONFLICT",
message: "Subscriber with this email already exists",
})
});
}
const subscriber = await prisma.subscriber.create({
@@ -76,10 +76,10 @@ export const createSubscriber = authProcedure
}
: undefined,
},
})
});
return { subscriber }
})
return { subscriber };
});
const updateSubscriberSchema = z.object({
id: z.string(),
@@ -93,10 +93,10 @@ const updateSubscriberSchema = z.object({
z.object({
key: z.string().min(1),
value: z.string().min(1),
})
}),
)
.optional(),
})
});
export const updateSubscriber = authProcedure
.input(updateSubscriberSchema)
@@ -106,13 +106,13 @@ export const updateSubscriber = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const subscriber = await prisma.subscriber.findFirst({
@@ -123,25 +123,25 @@ export const updateSubscriber = authProcedure
include: {
ListSubscribers: true,
},
})
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
})
});
}
// Get current list IDs
const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId)
const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId);
// Find lists to add and remove
const listsToAdd = input.listIds.filter(
(id) => !currentListIds.includes(id)
)
(id) => !currentListIds.includes(id),
);
const listsToRemove = currentListIds.filter(
(id) => !input.listIds.includes(id)
)
(id) => !input.listIds.includes(id),
);
const updatedSubscriber = await prisma.subscriber.update({
where: { id: input.id },
@@ -176,17 +176,17 @@ export const updateSubscriber = authProcedure
},
},
},
})
});
return { subscriber: updatedSubscriber }
})
return { subscriber: updatedSubscriber };
});
export const deleteSubscriber = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -194,13 +194,13 @@ export const deleteSubscriber = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const subscriber = await prisma.subscriber.findFirst({
@@ -208,21 +208,21 @@ export const deleteSubscriber = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
})
});
}
await prisma.subscriber.delete({
where: { id: input.id },
})
});
return { success: true }
})
return { success: true };
});
export const importSubscribers = authProcedure
.input(
@@ -230,41 +230,41 @@ export const importSubscribers = authProcedure
file: z.instanceof(FormData),
organizationId: z.string(),
listId: z.string().optional(),
})
}),
)
.mutation(async ({ input }) => {
const file = input.file.get("file") as File
const file = input.file.get("file") as File;
if (!file) {
throw new Error("No file provided")
throw new Error("No file provided");
}
const buffer = Buffer.from(await file.arrayBuffer())
const records: any[] = []
const buffer = Buffer.from(await file.arrayBuffer());
const records: any[] = [];
// Parse CSV
await new Promise((resolve, reject) => {
const parser = parse({
columns: true,
skip_empty_lines: true,
})
});
parser.on("readable", function () {
let record
let record;
while ((record = parser.read()) !== null) {
records.push(record)
records.push(record);
}
})
});
parser.on("error", function (err) {
reject(err)
})
reject(err);
});
parser.on("end", function () {
resolve(undefined)
})
resolve(undefined);
});
Readable.from(buffer).pipe(parser)
})
Readable.from(buffer).pipe(parser);
});
// Validate and transform records
const subscribers = records.map((record) => ({
@@ -283,7 +283,7 @@ export const importSubscribers = authProcedure
? record.tags.split(",").map((t: string) => t.trim())
: [],
organizationId: input.organizationId,
}))
}));
// Import subscribers
const result = await prisma.$transaction(async (tx) => {
@@ -298,7 +298,7 @@ export const importSubscribers = authProcedure
},
create: sub,
update: sub,
})
});
if (input.listId) {
await tx.listSubscriber.upsert({
@@ -313,27 +313,27 @@ export const importSubscribers = authProcedure
subscriberId: subscriber.id,
},
update: {},
})
});
}
return subscriber
})
)
return subscriber;
}),
);
return imported
})
return imported;
});
return {
count: result.length,
}
})
};
});
export const unsubscribeToggle = authProcedure
.input(
z.object({
listSubscriberId: z.string(),
organizationId: z.string(),
})
}),
)
.mutation(async ({ input, ctx }) => {
const org = await prisma.userOrganization.findFirst({
@@ -341,13 +341,13 @@ export const unsubscribeToggle = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!org) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const listSubscriber = await prisma.listSubscriber.findFirst({
@@ -357,13 +357,13 @@ export const unsubscribeToggle = authProcedure
organizationId: org.organizationId,
},
},
})
});
if (!listSubscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List subscriber not found",
})
});
}
const updated = await prisma.listSubscriber.update({
@@ -371,13 +371,13 @@ export const unsubscribeToggle = authProcedure
data: {
unsubscribedAt: listSubscriber.unsubscribedAt ? null : new Date(),
},
})
});
return {
success: true,
subbed: !updated.unsubscribedAt,
}
})
};
});
export const publicUnsubscribe = publicProcedure
.input(z.object({ sid: z.string(), cid: z.string() }))
@@ -395,12 +395,12 @@ export const publicUnsubscribe = publicProcedure
},
unsubscribedAt: null,
},
})
});
if (!listSubscribers.length) {
return {
success: true,
}
};
}
await prisma.listSubscriber.updateMany({
@@ -412,35 +412,35 @@ export const publicUnsubscribe = publicProcedure
data: {
unsubscribedAt: new Date(),
},
})
});
await prisma.campaign
.update({
where: { id: input.cid },
data: { unsubscribedCount: { increment: 1 } },
})
.catch(() => {})
.catch(() => {});
return {
success: true,
}
};
} catch (error) {
if (error instanceof TRPCError) {
throw error
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to unsubscribe",
})
});
}
})
});
export const verifyEmail = publicProcedure
.input(
z.object({
token: z.string(),
})
}),
)
.mutation(async ({ input }) => {
const subscriber = await prisma.subscriber.findFirst({
@@ -450,13 +450,13 @@ export const verifyEmail = publicProcedure
gt: new Date(),
},
},
})
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invalid or expired verification token",
})
});
}
await prisma.subscriber.update({
@@ -466,7 +466,7 @@ export const verifyEmail = publicProcedure
emailVerificationToken: null,
emailVerificationTokenExpiresAt: null,
},
})
});
return { success: true }
})
return { success: true };
});

View File

@@ -1,10 +1,10 @@
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 listSubscribers = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
@@ -14,13 +14,13 @@ export const listSubscribers = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const where: Prisma.SubscriberWhereInput = {
@@ -33,7 +33,7 @@ export const listSubscribers = authProcedure
],
}
: {}),
}
};
const promises = {
subscribersList: prisma.subscriber.findMany({
@@ -61,11 +61,11 @@ export const listSubscribers = authProcedure
},
}),
totalItems: prisma.subscriber.count({ where }),
}
};
const result = await resolveProps(promises)
const result = await resolveProps(promises);
const totalPages = Math.ceil(result.totalItems / input.perPage)
const totalPages = Math.ceil(result.totalItems / input.perPage);
return {
subscribers: result.subscribersList,
@@ -76,15 +76,15 @@ export const listSubscribers = authProcedure
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
};
});
export const getSubscriber = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -92,13 +92,13 @@ export const getSubscriber = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const subscriber = await prisma.subscriber.findFirst({
@@ -119,14 +119,14 @@ export const getSubscriber = authProcedure
Metadata: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
})
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
})
});
}
return subscriber
})
return subscriber;
});

View File

@@ -1,4 +1,4 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
createSubscriber,
updateSubscriber,
@@ -7,8 +7,8 @@ import {
publicUnsubscribe,
unsubscribeToggle,
verifyEmail,
} from "./mutation"
import { getSubscriber, listSubscribers } from "./query"
} from "./mutation";
import { getSubscriber, listSubscribers } from "./query";
export const subscriberRouter = router({
create: createSubscriber,
@@ -20,4 +20,4 @@ export const subscriberRouter = router({
unsubscribe: publicUnsubscribe,
unsubscribeToggle,
verifyEmail,
})
});

View File

@@ -1,4 +1,4 @@
import swaggerJSDoc from "swagger-jsdoc"
import swaggerJSDoc from "swagger-jsdoc";
const swaggerDefinition = {
openapi: "3.0.0",
@@ -6,13 +6,13 @@ const swaggerDefinition = {
title: "Cat Letter API",
version: "1.0.0",
},
}
};
const options = {
swaggerDefinition,
apis: ["./src/api/server.ts"],
}
};
const swaggerSpec = swaggerJSDoc(options)
const swaggerSpec = swaggerJSDoc(options);
export default swaggerSpec
export default swaggerSpec;

View File

@@ -1,22 +1,22 @@
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 contentSchema = z
.string()
.min(1, "HTML content is required")
.refine(
(content) => content.includes("{{content}}"),
"Content must include the {{content}} placeholder"
)
"Content must include the {{content}} placeholder",
);
const createTemplateSchema = z.object({
name: z.string().min(1, "Template name is required"),
description: z.string().nullable().optional(),
content: contentSchema,
organizationId: z.string(),
})
});
export const createTemplate = authProcedure
.input(createTemplateSchema)
@@ -26,13 +26,13 @@ export const createTemplate = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const template = await prisma.template.create({
@@ -42,16 +42,16 @@ export const createTemplate = authProcedure
content: input.content,
organizationId: input.organizationId,
},
})
});
return { template }
})
return { template };
});
export const updateTemplate = authProcedure
.input(
createTemplateSchema.extend({
id: z.string(),
})
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -59,13 +59,13 @@ export const updateTemplate = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const template = await prisma.template.findFirst({
@@ -73,13 +73,13 @@ export const updateTemplate = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
})
});
}
const updatedTemplate = await prisma.template.update({
@@ -89,17 +89,17 @@ export const updateTemplate = authProcedure
description: input.description,
content: input.content,
},
})
});
return { template: updatedTemplate }
})
return { template: updatedTemplate };
});
export const deleteTemplate = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -107,13 +107,13 @@ export const deleteTemplate = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const template = await prisma.template.findFirst({
@@ -121,18 +121,18 @@ export const deleteTemplate = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
})
});
}
await prisma.template.delete({
where: { id: input.id },
})
});
return { success: true }
})
return { success: true };
});

View File

@@ -1,9 +1,9 @@
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 listTemplates = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
@@ -13,13 +13,13 @@ export const listTemplates = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const where: Prisma.TemplateWhereInput = {
@@ -32,7 +32,7 @@ export const listTemplates = authProcedure
],
}
: {}),
}
};
const [total, templates] = await Promise.all([
prisma.template.count({ where }),
@@ -42,9 +42,9 @@ export const listTemplates = authProcedure
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
])
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
templates,
@@ -55,15 +55,15 @@ export const listTemplates = authProcedure
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
};
});
export const getTemplate = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
@@ -71,13 +71,13 @@ export const getTemplate = authProcedure
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
});
}
const template = await prisma.template.findFirst({
@@ -85,14 +85,14 @@ export const getTemplate = authProcedure
id: input.id,
organizationId: input.organizationId,
},
})
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
})
});
}
return template
})
return template;
});

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc"
import { createTemplate, updateTemplate, deleteTemplate } from "./mutation"
import { getTemplate, listTemplates } from "./query"
import { router } from "../trpc";
import { createTemplate, updateTemplate, deleteTemplate } from "./mutation";
import { getTemplate, listTemplates } from "./query";
export const templateRouter = router({
create: createTemplate,
@@ -8,4 +8,4 @@ export const templateRouter = router({
delete: deleteTemplate,
get: getTemplate,
list: listTemplates,
})
});

View File

@@ -1,81 +1,81 @@
import { initTRPC, TRPCError } from "@trpc/server"
import * as trpcExpress from "@trpc/server/adapters/express"
import { verifyToken } from "./utils/auth"
import { prisma } from "./utils/prisma"
import { tokenPayloadSchema } from "./utils/token"
import SuperJSON from "superjson"
import { initTRPC, TRPCError } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";
import { verifyToken } from "./utils/auth";
import { prisma } from "./utils/prisma";
import { tokenPayloadSchema } from "./utils/token";
import SuperJSON from "superjson";
interface User {
id: string
id: string;
}
interface Context {
user?: User
user?: User;
}
export const createContext = async ({
req,
}: trpcExpress.CreateExpressContextOptions): Promise<Context> => {
const authHeader = req.headers.authorization
const authHeader = req.headers.authorization;
if (!authHeader) {
return {}
return {};
}
try {
const token = authHeader.split(" ")[1]
const token = authHeader.split(" ")[1];
if (!token) {
return {}
return {};
}
const decodedRaw = verifyToken(token)
const decodedRaw = verifyToken(token);
const result = tokenPayloadSchema.safeParse(decodedRaw)
const result = tokenPayloadSchema.safeParse(decodedRaw);
if (!result.success) {
return {}
return {};
}
const decoded = result.data
const decoded = result.data;
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: { id: true, pwdVersion: true },
})
});
if (!user) {
return {}
return {};
}
if (user.pwdVersion !== decoded.version) {
return {}
return {};
}
return { user }
return { user };
} catch {
return {}
return {};
}
}
};
const t = initTRPC.context<Context>().create({
transformer: SuperJSON,
})
});
export const router = t.router
export const publicProcedure = t.procedure
export const router = t.router;
export const publicProcedure = t.procedure;
export const isAuthedMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to access this resource",
})
});
}
return next({
ctx: {
user: ctx.user,
},
})
})
});
});
export const authProcedure = t.procedure.use(isAuthedMiddleware)
export const authProcedure = t.procedure.use(isAuthedMiddleware);

View File

@@ -1,9 +1,9 @@
import { Organization } from "../prisma/client"
import { Organization } from "../prisma/client";
declare global {
export namespace Express {
export interface Request {
organization: Organization
organization: Organization;
}
}
}

View File

@@ -1,41 +1,41 @@
import { z } from "zod"
import { publicProcedure, authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { comparePasswords, generateToken, hashPassword } from "../utils/auth"
import { TRPCError } from "@trpc/server"
import { z } from "zod";
import { publicProcedure, authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { comparePasswords, generateToken, hashPassword } from "../utils/auth";
import { TRPCError } from "@trpc/server";
const signUpSchema = z.object({
email: z.string().email().min(1, "Email is required"),
password: z.string().min(1, "Password is required"),
name: z.string().min(1, "Name is required"),
})
});
export const signup = publicProcedure
.input(signUpSchema)
.mutation(async ({ input }) => {
const { email, password, name } = input
const { email, password, name } = input;
if (await prisma.user.count()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Bad request",
})
});
}
const existingUser = await prisma.user.findUnique({
where: {
email,
},
})
});
if (existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `User with email ${email} already exists`,
})
});
}
const hashedPassword = await hashPassword(password)
const hashedPassword = await hashPassword(password);
const user = await prisma.user.create({
data: {
email,
@@ -46,24 +46,24 @@ export const signup = publicProcedure
id: true,
pwdVersion: true,
},
})
});
const token = generateToken(user.id, user.pwdVersion)
const token = generateToken(user.id, user.pwdVersion);
return {
token,
}
})
};
});
export const login = publicProcedure
.input(
z.object({
email: z.string().email().min(1, "Email is required"),
password: z.string().min(1, "Password is required"),
})
}),
)
.mutation(async ({ input }) => {
const { email, password } = input
const { email, password } = input;
const user = await prisma.user.findUnique({
where: { email },
@@ -73,45 +73,45 @@ export const login = publicProcedure
pwdVersion: true,
UserOrganizations: true,
},
})
});
if (!user) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid credentials",
})
});
}
const isValidPassword = await comparePasswords(password, user.password)
const isValidPassword = await comparePasswords(password, user.password);
if (!isValidPassword) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Invalid credentials",
})
});
}
const token = generateToken(user.id, user.pwdVersion)
const token = generateToken(user.id, user.pwdVersion);
return {
token,
user,
}
})
};
});
const updateProfileSchema = z.object({
name: z.string().min(1, "Name is required."),
email: z.string().email("Invalid email address.").toLowerCase(),
})
});
export const updateProfile = authProcedure
.input(updateProfileSchema)
.mutation(async ({ ctx, input }) => {
const { name, email } = input
const userId = ctx.user.id
const { name, email } = input;
const userId = ctx.user.id;
const currentUser = await prisma.user.findUnique({
where: { id: userId },
})
});
if (currentUser?.email !== email) {
const existingUserWithEmail = await prisma.user.findFirst({
@@ -119,12 +119,12 @@ export const updateProfile = authProcedure
email: email,
id: { not: userId },
},
})
});
if (existingUserWithEmail) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email address is already in use by another account.",
})
});
}
}
@@ -144,44 +144,44 @@ export const updateProfile = authProcedure
},
},
},
})
});
return { user: updatedUser }
})
return { user: updatedUser };
});
const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string().min(8, "New password must be at least 8 characters."),
})
});
export const changePassword = authProcedure
.input(changePasswordSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user.id
const { currentPassword, newPassword } = input
const userId = ctx.user.id;
const { currentPassword, newPassword } = input;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { password: true, pwdVersion: true },
})
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found." })
throw new TRPCError({ code: "NOT_FOUND", message: "User not found." });
}
const isValidPassword = await comparePasswords(
currentPassword,
user.password
)
user.password,
);
if (!isValidPassword) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Incorrect current password.",
})
});
}
const hashedNewPassword = await hashPassword(newPassword)
const newPwdVersion = (user.pwdVersion || 0) + 1
const hashedNewPassword = await hashPassword(newPassword);
const newPwdVersion = (user.pwdVersion || 0) + 1;
await prisma.user.update({
where: { id: userId },
@@ -189,9 +189,9 @@ export const changePassword = authProcedure
password: hashedNewPassword,
pwdVersion: newPwdVersion,
},
})
});
const newToken = generateToken(userId, newPwdVersion)
const newToken = generateToken(userId, newPwdVersion);
return { success: true, token: newToken }
})
return { success: true, token: newToken };
});

View File

@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server"
import { authProcedure, publicProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server";
import { authProcedure, publicProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
export const me = authProcedure.query(async ({ ctx }) => {
const user = await prisma.user.findUnique({
@@ -12,19 +12,19 @@ export const me = authProcedure.query(async ({ ctx }) => {
},
},
},
})
});
if (!user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User not found",
})
});
}
return user
})
return user;
});
export const isFirstUser = publicProcedure.query(async () => {
const user = await prisma.user.count()
return user === 0
})
const user = await prisma.user.count();
return user === 0;
});

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc"
import { login, signup, updateProfile, changePassword } from "./mutation"
import { me, isFirstUser } from "./query"
import { router } from "../trpc";
import { login, signup, updateProfile, changePassword } from "./mutation";
import { me, isFirstUser } from "./query";
export const userRouter = router({
signup,
@@ -9,4 +9,4 @@ export const userRouter = router({
isFirstUser,
updateProfile,
changePassword,
})
});

View File

@@ -1,22 +1,24 @@
import jwt from "jsonwebtoken"
import bcrypt from "bcryptjs"
import { env } from "../constants"
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { env } from "../constants";
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10)
return bcrypt.hash(password, 10);
}
export async function comparePasswords(
password: string,
hashedPassword: string
hashedPassword: string,
) {
return bcrypt.compare(password, hashedPassword)
return bcrypt.compare(password, hashedPassword);
}
export function generateToken(userId: string, version: number) {
return jwt.sign({ id: userId, version }, env.JWT_SECRET, { expiresIn: "30d" })
return jwt.sign({ id: userId, version }, env.JWT_SECRET, {
expiresIn: "30d",
});
}
export function verifyToken(token: string) {
return jwt.verify(token, env.JWT_SECRET)
return jwt.verify(token, env.JWT_SECRET);
}

View File

@@ -1,18 +1,18 @@
const formatLog = (messages: unknown[]) => {
return `[${new Date().toISOString()}] ${messages.join(" ")}`
}
return `[${new Date().toISOString()}] ${messages.join(" ")}`;
};
export const logger = {
log(...messages: unknown[]) {
console.log(formatLog(messages))
console.log(formatLog(messages));
},
info(...messages: unknown[]) {
console.log(formatLog(messages))
console.log(formatLog(messages));
},
error(...messages: unknown[]) {
console.error(formatLog(messages))
console.error(formatLog(messages));
},
warn(...messages: unknown[]) {
console.warn(formatLog(messages))
console.warn(formatLog(messages));
},
}
};

View File

@@ -1,14 +1,14 @@
export async function resolveProps<T extends Record<string, Promise<any>>>(
promises: T
promises: T,
): Promise<{ [K in keyof T]: Awaited<T[K]> }> {
const keys = Object.keys(promises)
const values = await Promise.all(Object.values(promises))
const keys = Object.keys(promises);
const values = await Promise.all(Object.values(promises));
return keys.reduce(
(acc, key, index) => {
acc[key as keyof T] = values[index]
return acc
acc[key as keyof T] = values[index];
return acc;
},
{} as { [K in keyof T]: Awaited<T[K]> }
)
{} as { [K in keyof T]: Awaited<T[K]> },
);
}

View File

@@ -1,122 +1,125 @@
import { replacePlaceholders } from "./placeholder-parser"
import { describe, it, expect } from "vitest"
import { replacePlaceholders } from "./placeholder-parser";
import { describe, it, expect } from "vitest";
describe("replacePlaceholders", () => {
it("should replace a single placeholder", () => {
const template = "Hello {{subscriber.name}}!"
const data = { "subscriber.name": "John" }
expect(replacePlaceholders(template, data)).toBe("Hello John!")
})
const template = "Hello {{subscriber.name}}!";
const data = { "subscriber.name": "John" };
expect(replacePlaceholders(template, data)).toBe("Hello John!");
});
it("should replace multiple placeholders", () => {
const template = "Order for {{subscriber.name}} from {{organization.name}}."
const template =
"Order for {{subscriber.name}} from {{organization.name}}.";
const data = {
"subscriber.name": "Alice",
"organization.name": "Org Inc",
}
};
expect(replacePlaceholders(template, data)).toBe(
"Order for Alice from Org Inc."
)
})
"Order for Alice from Org Inc.",
);
});
it("should handle templates with no placeholders", () => {
const template = "This is a static string."
const data = { "subscriber.name": "Bob" }
expect(replacePlaceholders(template, data)).toBe("This is a static string.")
})
const template = "This is a static string.";
const data = { "subscriber.name": "Bob" };
expect(replacePlaceholders(template, data)).toBe(
"This is a static string.",
);
});
it("should handle empty data", () => {
const template = "Hello {{subscriber.name}}!"
const data = {}
const template = "Hello {{subscriber.name}}!";
const data = {};
expect(replacePlaceholders(template, data)).toBe(
"Hello {{subscriber.name}}!"
)
})
"Hello {{subscriber.name}}!",
);
});
it("should handle empty template string", () => {
const template = ""
const data = { "subscriber.name": "Eve" }
expect(replacePlaceholders(template, data)).toBe("")
})
const template = "";
const data = { "subscriber.name": "Eve" };
expect(replacePlaceholders(template, data)).toBe("");
});
it("should handle placeholders with special characters in keys", () => {
const template = "Link: {{unsubscribe_link}}"
const data = { unsubscribe_link: "http://example.com/unsubscribe" }
const template = "Link: {{unsubscribe_link}}";
const data = { unsubscribe_link: "http://example.com/unsubscribe" };
expect(replacePlaceholders(template, data)).toBe(
"Link: http://example.com/unsubscribe"
)
})
"Link: http://example.com/unsubscribe",
);
});
it("should replace all occurrences of a placeholder", () => {
const template = "Hi {{subscriber.name}}, welcome {{subscriber.name}}."
const data = { "subscriber.name": "Charlie" }
const template = "Hi {{subscriber.name}}, welcome {{subscriber.name}}.";
const data = { "subscriber.name": "Charlie" };
expect(replacePlaceholders(template, data)).toBe(
"Hi Charlie, welcome Charlie."
)
})
"Hi Charlie, welcome Charlie.",
);
});
it("should not replace partial matches", () => {
const template = "Hello {{subscriber.name}} and {{subscriber.names}}"
const data = { "subscriber.name": "David" }
const template = "Hello {{subscriber.name}} and {{subscriber.names}}";
const data = { "subscriber.name": "David" };
expect(replacePlaceholders(template, data)).toBe(
"Hello David and {{subscriber.names}}"
)
})
"Hello David and {{subscriber.names}}",
);
});
it("should correctly replace various types of placeholders", () => {
const template =
"Email: {{subscriber.email}}, Campaign: {{campaign.name}}, Org: {{organization.name}}, Unsub: {{unsubscribe_link}}, Date: {{current_date}}"
"Email: {{subscriber.email}}, Campaign: {{campaign.name}}, Org: {{organization.name}}, Unsub: {{unsubscribe_link}}, Date: {{current_date}}";
const data = {
"subscriber.email": "test@example.com",
"campaign.name": "Newsletter Q1",
"organization.name": "MyCompany",
unsubscribe_link: "domain.com/unsub",
current_date: "2024-01-01",
}
};
expect(replacePlaceholders(template, data)).toBe(
"Email: test@example.com, Campaign: Newsletter Q1, Org: MyCompany, Unsub: domain.com/unsub, Web: domain.com/web, Date: 2024-01-01"
)
})
"Email: test@example.com, Campaign: Newsletter Q1, Org: MyCompany, Unsub: domain.com/unsub, Web: domain.com/web, Date: 2024-01-01",
);
});
it("should handle data with undefined values gracefully", () => {
const template = "Hello {{subscriber.name}} and {{campaign.name}}!"
const template = "Hello {{subscriber.name}} and {{campaign.name}}!";
const data = {
"subscriber.name": "DefinedName",
"campaign.name": undefined,
} as { [key: string]: string | undefined } // Added type assertion for clarity
} as { [key: string]: string | undefined }; // Added type assertion for clarity
expect(replacePlaceholders(template, data)).toBe(
"Hello DefinedName and {{campaign.name}}!"
)
})
"Hello DefinedName and {{campaign.name}}!",
);
});
it("should replace placeholders with leading spaces inside braces", () => {
const template = "Hello {{ subscriber.name }}!"
const data = { "subscriber.name": "SpacedJohn" }
expect(replacePlaceholders(template, data)).toBe("Hello SpacedJohn!")
})
const template = "Hello {{ subscriber.name }}!";
const data = { "subscriber.name": "SpacedJohn" };
expect(replacePlaceholders(template, data)).toBe("Hello SpacedJohn!");
});
it("should replace placeholders with trailing spaces inside braces", () => {
const template = "Hello {{subscriber.name }}!"
const data = { "subscriber.name": "SpacedAlice" }
expect(replacePlaceholders(template, data)).toBe("Hello SpacedAlice!")
})
const template = "Hello {{subscriber.name }}!";
const data = { "subscriber.name": "SpacedAlice" };
expect(replacePlaceholders(template, data)).toBe("Hello SpacedAlice!");
});
it("should replace placeholders with leading and trailing spaces inside braces", () => {
const template = "Hello {{ subscriber.name }}!"
const data = { "subscriber.name": "SpacedBob" }
expect(replacePlaceholders(template, data)).toBe("Hello SpacedBob!")
})
const template = "Hello {{ subscriber.name }}!";
const data = { "subscriber.name": "SpacedBob" };
expect(replacePlaceholders(template, data)).toBe("Hello SpacedBob!");
});
it("should replace multiple placeholders with various spacing", () => {
const template =
"Hi {{subscriber.name}}, welcome {{ organization.name }}. Date: {{current_date}}."
"Hi {{subscriber.name}}, welcome {{ organization.name }}. Date: {{current_date}}.";
const data = {
"subscriber.name": "SpacedEve",
"organization.name": "Org Spaced Inc.",
current_date: "2024-02-20",
}
};
expect(replacePlaceholders(template, data)).toBe(
"Hi SpacedEve, welcome Org Spaced Inc.. Date: 2024-02-20."
)
})
})
"Hi SpacedEve, welcome Org Spaced Inc.. Date: 2024-02-20.",
);
});
});

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