mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/documents
This commit is contained in:
commit
bbb367cc53
12 changed files with 895 additions and 93 deletions
|
|
@ -20,7 +20,10 @@ export function ContactFormGridWithDetails() {
|
|||
Contact
|
||||
</h2>
|
||||
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
|
||||
We'd love to hear from you. Schedule a meeting or send us an email.
|
||||
We'd love to hear from you!
|
||||
</p>
|
||||
<p className="mt-4 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
|
||||
Schedule a meeting with our Head of Product, Eric Lammertsma, or send us an email.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col items-center gap-6">
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ export function FeaturesCards() {
|
|||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Choose from 100+ leading LLMs and seamlessly call any model on demand.
|
||||
Choose from 100+ leading LLMs, seamlessly calling any model on demand. Even run
|
||||
on-prem local LLM inference via vLLM, Ollama, llama.cpp, LM Studio, and more.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -74,9 +75,9 @@ export function FeaturesCards() {
|
|||
const CardDecorator = ({ children }: { children: ReactNode }) => (
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative mx-auto size-36 [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
|
||||
className="relative mx-auto size-36 mask-[radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
|
||||
>
|
||||
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
|
||||
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-size-[24px_24px] opacity-10" />
|
||||
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ function ContactSalesButton() {
|
|||
return (
|
||||
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="https://calendly.com/eric-surfsense/surfsense-meeting"
|
||||
target="_blank"
|
||||
href="/contact"
|
||||
//target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
|
||||
>
|
||||
|
|
@ -288,7 +288,7 @@ const CollisionMechanism = React.forwardRef<
|
|||
}
|
||||
};
|
||||
|
||||
const animationInterval = setInterval(checkCollision, 50);
|
||||
const animationInterval = setInterval(checkCollision, 100);
|
||||
|
||||
return () => clearInterval(animationInterval);
|
||||
}, [cycleCollisionDetected, containerRef]);
|
||||
|
|
@ -338,7 +338,7 @@ const CollisionMechanism = React.forwardRef<
|
|||
repeatDelay: beamOptions.repeatDelay || 0,
|
||||
}}
|
||||
className={cn(
|
||||
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent",
|
||||
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent will-change-transform",
|
||||
beamOptions.className
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,10 +19,9 @@ export const Navbar = () => {
|
|||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ name: "Contact Us", link: "/contact" },
|
||||
{ name: "Pricing", link: "/pricing" },
|
||||
{ name: "Contact\u00A0Us", link: "/contact" },
|
||||
{ name: "Changelog", link: "/changelog" },
|
||||
// { name: "Sign In", link: "/login" },
|
||||
{ name: "Docs", link: "/docs" },
|
||||
];
|
||||
|
||||
|
|
@ -61,10 +60,13 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
: "bg-transparent border border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center gap-0.5">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex flex-1 flex-row items-center gap-0.5 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2">
|
||||
{navItems.map((navItem: any, idx: number) => (
|
||||
<Link
|
||||
|
|
@ -139,10 +141,13 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
|
|||
)}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex flex-row items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export interface DocumentMentionPickerRef {
|
||||
selectHighlighted: () => void;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ interface PricingProps {
|
|||
export function Pricing({
|
||||
plans,
|
||||
title = "Simple, Transparent Pricing",
|
||||
description = "Choose the plan that works for you\nAll plans include access to our platform, lead generation tools, and dedicated support.",
|
||||
description = "Choose the plan that works for you\nAll plans include access to our SurfSense AI workspace and community support.",
|
||||
}: PricingProps) {
|
||||
const [isMonthly, setIsMonthly] = useState(true);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
|
@ -69,7 +69,7 @@ export function Pricing({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="container py-20">
|
||||
<div className="container mx-auto py-20">
|
||||
<div className="text-center space-y-4 mb-12">
|
||||
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h2>
|
||||
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
|
||||
|
|
|
|||
|
|
@ -9,20 +9,39 @@ const demoPlans = [
|
|||
yearlyPrice: "0",
|
||||
period: "forever",
|
||||
features: [
|
||||
"Community support",
|
||||
"Supports 100+ LLMs",
|
||||
"Supports local Ollama or vLLM setups",
|
||||
"6000+ Embedding Models",
|
||||
"Supports OpenAI spec and LiteLLM",
|
||||
"Supports local vLLM or Ollama setups",
|
||||
"6000+ embedding models",
|
||||
"50+ File extensions supported.",
|
||||
"Podcasts support with local TTS providers.",
|
||||
"Connects with 15+ external sources.",
|
||||
"Connects with 15+ external sources, like Drive and Notion.",
|
||||
"Cross-Browser Extension for dynamic webpages including authenticated content",
|
||||
"Role-based access permissions",
|
||||
"Collaboration and multiplayer features",
|
||||
"Upcoming: Note Management",
|
||||
"Role-based access control (RBAC)",
|
||||
"Collaboration and team features",
|
||||
],
|
||||
description: "Open source version with powerful features",
|
||||
buttonText: "Get Started",
|
||||
buttonText: "Dive In",
|
||||
href: "/docs",
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
name: "CLOUD",
|
||||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "in beta",
|
||||
features: [
|
||||
"Everything in Community",
|
||||
"Email support",
|
||||
"Get started in seconds",
|
||||
"Instant access to new features",
|
||||
"Easy access from anywhere",
|
||||
"Remote team management and collaboration",
|
||||
],
|
||||
description: "Instant access for individuals and teams",
|
||||
buttonText: "Get Started",
|
||||
href: "/",
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
|
|
@ -32,13 +51,16 @@ const demoPlans = [
|
|||
period: "",
|
||||
features: [
|
||||
"Everything in Community",
|
||||
"Priority Support",
|
||||
"Advanced security features",
|
||||
"Priority support",
|
||||
"White-glove setup and deployment",
|
||||
"Monthly managed updates and maintenance",
|
||||
"On-prem or VPC deployment",
|
||||
"Audit logs and compliance",
|
||||
"SSO, OIDC & SAML",
|
||||
"SLA guarantee",
|
||||
"Uptime guarantee on VPC",
|
||||
],
|
||||
description: "For large organizations with specific needs",
|
||||
description: "Professional, customized setup for large organizations",
|
||||
buttonText: "Contact Sales",
|
||||
href: "/contact",
|
||||
isPopular: false,
|
||||
|
|
@ -47,7 +69,7 @@ const demoPlans = [
|
|||
|
||||
function PricingBasic() {
|
||||
return (
|
||||
<Pricing plans={demoPlans} title="SurfSense Pricing" description="Choose that works for you" />
|
||||
<Pricing plans={demoPlans} title="SurfSense Pricing" description="Choose what works for you" />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,71 +1,802 @@
|
|||
"use client";
|
||||
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
|
||||
export function ThemeTogglerComponent() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
// ///////////////////////////////////////////////////////////////////////////
|
||||
// Types
|
||||
export type AnimationVariant =
|
||||
| "circle"
|
||||
| "rectangle"
|
||||
| "gif"
|
||||
| "polygon"
|
||||
| "circle-blur";
|
||||
export type AnimationStart =
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "center"
|
||||
| "top-center"
|
||||
| "bottom-center"
|
||||
| "bottom-up"
|
||||
| "top-down"
|
||||
| "left-right"
|
||||
| "right-left";
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
isClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
theme === "dark" ? setTheme("light") : setTheme("dark");
|
||||
}}
|
||||
className="w-8 h-8 flex hover:bg-gray-50 dark:hover:bg-white/[0.1] rounded-lg items-center cursor-pointer justify-center outline-none focus:ring-0 focus:outline-none active:ring-0 active:outline-none overflow-hidden"
|
||||
>
|
||||
{theme === "light" && (
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{
|
||||
x: 40,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<SunIcon className="h-4 w-4 flex-shrink-0 dark:text-neutral-500 text-neutral-700" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{theme === "dark" && (
|
||||
<motion.div
|
||||
key={theme}
|
||||
initial={{
|
||||
x: 40,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{
|
||||
ease: "easeOut",
|
||||
duration: 0.3,
|
||||
}}
|
||||
>
|
||||
<MoonIcon className="h-4 w-4 flex-shrink-0 " />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
interface Animation {
|
||||
name: string;
|
||||
css: string;
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////
|
||||
// Helper functions
|
||||
|
||||
const getPositionCoords = (position: AnimationStart) => {
|
||||
switch (position) {
|
||||
case "top-left":
|
||||
return { cx: "0", cy: "0" };
|
||||
case "top-right":
|
||||
return { cx: "40", cy: "0" };
|
||||
case "bottom-left":
|
||||
return { cx: "0", cy: "40" };
|
||||
case "bottom-right":
|
||||
return { cx: "40", cy: "40" };
|
||||
case "top-center":
|
||||
return { cx: "20", cy: "0" };
|
||||
case "bottom-center":
|
||||
return { cx: "20", cy: "40" };
|
||||
case "bottom-up":
|
||||
case "top-down":
|
||||
case "left-right":
|
||||
case "right-left":
|
||||
return { cx: "20", cy: "20" };
|
||||
}
|
||||
};
|
||||
|
||||
const generateSVG = (variant: AnimationVariant, start: AnimationStart) => {
|
||||
if (variant === "circle-blur") {
|
||||
if (start === "center") {
|
||||
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="20" cy="20" r="18" fill="white" filter="url(%23blur)"/></svg>`;
|
||||
}
|
||||
const positionCoords = getPositionCoords(start);
|
||||
if (!positionCoords) {
|
||||
throw new Error(`Invalid start position: ${start}`);
|
||||
}
|
||||
const { cx, cy } = positionCoords;
|
||||
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs><circle cx="${cx}" cy="${cy}" r="18" fill="white" filter="url(%23blur)"/></svg>`;
|
||||
}
|
||||
|
||||
if (start === "center") return;
|
||||
|
||||
if (variant === "rectangle") return "";
|
||||
|
||||
const positionCoords = getPositionCoords(start);
|
||||
if (!positionCoords) {
|
||||
throw new Error(`Invalid start position: ${start}`);
|
||||
}
|
||||
const { cx, cy } = positionCoords;
|
||||
|
||||
if (variant === "circle") {
|
||||
return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><circle cx="${cx}" cy="${cy}" r="20" fill="white"/></svg>`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const getTransformOrigin = (start: AnimationStart) => {
|
||||
switch (start) {
|
||||
case "top-left":
|
||||
return "top left";
|
||||
case "top-right":
|
||||
return "top right";
|
||||
case "bottom-left":
|
||||
return "bottom left";
|
||||
case "bottom-right":
|
||||
return "bottom right";
|
||||
case "top-center":
|
||||
return "top center";
|
||||
case "bottom-center":
|
||||
return "bottom center";
|
||||
case "bottom-up":
|
||||
case "top-down":
|
||||
case "left-right":
|
||||
case "right-left":
|
||||
return "center";
|
||||
}
|
||||
};
|
||||
|
||||
export const createAnimation = (
|
||||
variant: AnimationVariant,
|
||||
start: AnimationStart = "center",
|
||||
blur = false,
|
||||
url?: string,
|
||||
): Animation => {
|
||||
const svg = generateSVG(variant, start);
|
||||
const transformOrigin = getTransformOrigin(start);
|
||||
|
||||
if (variant === "rectangle") {
|
||||
const getClipPath = (direction: AnimationStart) => {
|
||||
switch (direction) {
|
||||
case "bottom-up":
|
||||
return {
|
||||
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
case "top-down":
|
||||
return {
|
||||
from: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
case "left-right":
|
||||
return {
|
||||
from: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
case "right-left":
|
||||
return {
|
||||
from: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
case "top-left":
|
||||
return {
|
||||
from: "polygon(0% 0%, 0% 0%, 0% 0%, 0% 0%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
case "top-right":
|
||||
return {
|
||||
from: "polygon(100% 0%, 100% 0%, 100% 0%, 100% 0%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
case "bottom-left":
|
||||
return {
|
||||
from: "polygon(0% 100%, 0% 100%, 0% 100%, 0% 100%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
case "bottom-right":
|
||||
return {
|
||||
from: "polygon(100% 100%, 100% 100%, 100% 100%, 100% 100%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
from: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)",
|
||||
to: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const clipPath = getClipPath(start);
|
||||
|
||||
return {
|
||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-duration: 0.7s;
|
||||
animation-timing-function: var(--expo-out);
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.dark::view-transition-new(root) {
|
||||
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: ${clipPath.from};
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: ${clipPath.to};
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: ${clipPath.from};
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: ${clipPath.to};
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
if (variant === "circle" && start == "center") {
|
||||
return {
|
||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-duration: 0.7s;
|
||||
animation-timing-function: var(--expo-out);
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: reveal-light${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.dark::view-transition-new(root) {
|
||||
animation-name: reveal-dark${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
@keyframes reveal-dark${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: circle(0% at 50% 50%);
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: circle(100.0% at 50% 50%);
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reveal-light${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: circle(0% at 50% 50%);
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: circle(100.0% at 50% 50%);
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
if (variant === "gif") {
|
||||
return {
|
||||
name: `${variant}-${start}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-timing-function: var(--expo-in);
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
mask: url('${url}') center / 0 no-repeat;
|
||||
animation: scale 3s;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: scale 3s;
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
0% {
|
||||
mask-size: 0;
|
||||
}
|
||||
10% {
|
||||
mask-size: 50vmax;
|
||||
}
|
||||
90% {
|
||||
mask-size: 50vmax;
|
||||
}
|
||||
100% {
|
||||
mask-size: 2000vmax;
|
||||
}
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "circle-blur") {
|
||||
if (start === "center") {
|
||||
return {
|
||||
name: `${variant}-${start}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-timing-function: var(--expo-out);
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
mask: url('${svg}') center / 0 no-repeat;
|
||||
mask-origin: content-box;
|
||||
animation: scale 1s;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: scale 1s;
|
||||
transform-origin: center;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
to {
|
||||
mask-size: 350vmax;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${variant}-${start}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-timing-function: var(--expo-out);
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat;
|
||||
mask-origin: content-box;
|
||||
animation: scale 1s;
|
||||
transform-origin: ${transformOrigin};
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: scale 1s;
|
||||
transform-origin: ${transformOrigin};
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
to {
|
||||
mask-size: 350vmax;
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
if (variant === "polygon") {
|
||||
const getPolygonClipPaths = (position: AnimationStart) => {
|
||||
switch (position) {
|
||||
case "top-left":
|
||||
return {
|
||||
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
|
||||
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
|
||||
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
|
||||
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
|
||||
};
|
||||
case "top-right":
|
||||
return {
|
||||
darkFrom: "polygon(150% -71%, 250% 71%, 250% 71%, 150% -71%)",
|
||||
darkTo: "polygon(150% -71%, 250% 71%, 50% 171%, -71% 50%)",
|
||||
lightFrom: "polygon(-71% 50%, 50% 171%, 50% 171%, -71% 50%)",
|
||||
lightTo: "polygon(-71% 50%, 50% 171%, 250% 71%, 150% -71%)",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
darkFrom: "polygon(50% -71%, -50% 71%, -50% 71%, 50% -71%)",
|
||||
darkTo: "polygon(50% -71%, -50% 71%, 50% 171%, 171% 50%)",
|
||||
lightFrom: "polygon(171% 50%, 50% 171%, 50% 171%, 171% 50%)",
|
||||
lightTo: "polygon(171% 50%, 50% 171%, -50% 71%, 50% -71%)",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const clipPaths = getPolygonClipPaths(start);
|
||||
|
||||
return {
|
||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-duration: 0.7s;
|
||||
animation-timing-function: var(--expo-out);
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.dark::view-transition-new(root) {
|
||||
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: ${clipPaths.darkFrom};
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: ${clipPaths.darkTo};
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: ${clipPaths.lightFrom};
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: ${clipPaths.lightTo};
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle circle variants with start positions using clip-path
|
||||
if (variant === "circle" && start !== "center") {
|
||||
const getClipPathPosition = (position: AnimationStart) => {
|
||||
switch (position) {
|
||||
case "top-left":
|
||||
return "0% 0%";
|
||||
case "top-right":
|
||||
return "100% 0%";
|
||||
case "bottom-left":
|
||||
return "0% 100%";
|
||||
case "bottom-right":
|
||||
return "100% 100%";
|
||||
case "top-center":
|
||||
return "50% 0%";
|
||||
case "bottom-center":
|
||||
return "50% 100%";
|
||||
default:
|
||||
return "50% 50%";
|
||||
}
|
||||
};
|
||||
|
||||
const clipPosition = getClipPathPosition(start);
|
||||
|
||||
return {
|
||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-duration: 1s;
|
||||
animation-timing-function: var(--expo-out);
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation-name: reveal-light-${start}${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: none;
|
||||
z-index: -1;
|
||||
}
|
||||
.dark::view-transition-new(root) {
|
||||
animation-name: reveal-dark-${start}${blur ? "-blur" : ""};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
|
||||
@keyframes reveal-dark-${start}${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: circle(0% at ${clipPosition});
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: circle(150.0% at ${clipPosition});
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reveal-light-${start}${blur ? "-blur" : ""} {
|
||||
from {
|
||||
clip-path: circle(0% at ${clipPosition});
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
clip-path: circle(150.0% at ${clipPosition});
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${variant}-${start}${blur ? "-blur" : ""}`,
|
||||
css: `
|
||||
::view-transition-group(root) {
|
||||
animation-timing-function: var(--expo-in);
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
mask: url('${svg}') ${start.replace("-", " ")} / 0 no-repeat;
|
||||
mask-origin: content-box;
|
||||
animation: scale-${start}${blur ? "-blur" : ""} 1s;
|
||||
transform-origin: ${transformOrigin};
|
||||
${blur ? "filter: blur(2px);" : ""}
|
||||
}
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-old(root) {
|
||||
animation: scale-${start}${blur ? "-blur" : ""} 1s;
|
||||
transform-origin: ${transformOrigin};
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes scale-${start}${blur ? "-blur" : ""} {
|
||||
from {
|
||||
${blur ? "filter: blur(8px);" : ""}
|
||||
}
|
||||
${blur ? "50% { filter: blur(4px); }" : ""}
|
||||
to {
|
||||
mask-size: 2000vmax;
|
||||
${blur ? "filter: blur(0px);" : ""}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////
|
||||
// Custom hook for theme toggle functionality
|
||||
export const useThemeToggle = ({
|
||||
variant = "circle",
|
||||
start = "center",
|
||||
blur = false,
|
||||
gifUrl = "",
|
||||
}: {
|
||||
variant?: AnimationVariant;
|
||||
start?: AnimationStart;
|
||||
blur?: boolean;
|
||||
gifUrl?: string;
|
||||
} = {}) => {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
// Sync isDark state with resolved theme after hydration
|
||||
useEffect(() => {
|
||||
setIsDark(resolvedTheme === "dark");
|
||||
}, [resolvedTheme]);
|
||||
|
||||
const styleId = "theme-transition-styles";
|
||||
|
||||
const updateStyles = useCallback((css: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let styleElement = document.getElementById(styleId) as HTMLStyleElement;
|
||||
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement("style");
|
||||
styleElement.id = styleId;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
styleElement.textContent = css;
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setIsDark(!isDark);
|
||||
|
||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||
|
||||
updateStyles(animation.css);
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const switchTheme = () => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
switchTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(switchTheme);
|
||||
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
|
||||
|
||||
const setCrazyLightTheme = useCallback(() => {
|
||||
setIsDark(false);
|
||||
|
||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||
|
||||
updateStyles(animation.css);
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const switchTheme = () => {
|
||||
setTheme("light");
|
||||
};
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
switchTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(switchTheme);
|
||||
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||
|
||||
const setCrazyDarkTheme = useCallback(() => {
|
||||
setIsDark(true);
|
||||
|
||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||
|
||||
updateStyles(animation.css);
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const switchTheme = () => {
|
||||
setTheme("dark");
|
||||
};
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
switchTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(switchTheme);
|
||||
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||
|
||||
const setCrazySystemTheme = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const prefersDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
setIsDark(prefersDark);
|
||||
|
||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||
|
||||
updateStyles(animation.css);
|
||||
|
||||
const switchTheme = () => {
|
||||
setTheme("system");
|
||||
};
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
switchTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(switchTheme);
|
||||
}, [setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||
|
||||
return {
|
||||
isDark,
|
||||
setIsDark,
|
||||
toggleTheme,
|
||||
setCrazyLightTheme,
|
||||
setCrazyDarkTheme,
|
||||
setCrazySystemTheme,
|
||||
};
|
||||
};
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////
|
||||
// Theme Toggle Button Component (Sun/Moon Style)
|
||||
|
||||
export const ThemeToggleButton = ({
|
||||
className = "",
|
||||
variant = "circle",
|
||||
start = "center",
|
||||
blur = false,
|
||||
gifUrl = "",
|
||||
}: {
|
||||
className?: string;
|
||||
variant?: AnimationVariant;
|
||||
start?: AnimationStart;
|
||||
blur?: boolean;
|
||||
gifUrl?: string;
|
||||
}) => {
|
||||
const { isDark, toggleTheme } = useThemeToggle({
|
||||
variant,
|
||||
start,
|
||||
blur,
|
||||
gifUrl,
|
||||
});
|
||||
const clipId = useId();
|
||||
const clipPathId = `theme-toggle-clip-${clipId}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"size-10 cursor-pointer rounded-full p-2 transition-all duration-300 active:scale-95 bg-transparent",
|
||||
isDark ? "text-white" : "text-black",
|
||||
className,
|
||||
)}
|
||||
onClick={toggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
strokeLinecap="round"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<clipPath id={clipPathId}>
|
||||
<motion.path
|
||||
animate={{ y: isDark ? 10 : 0, x: isDark ? -12 : 0 }}
|
||||
transition={{ ease: "easeInOut", duration: 0.35 }}
|
||||
d="M0-5h30a1 1 0 0 0 9 13v24H0Z"
|
||||
/>
|
||||
</clipPath>
|
||||
<g clipPath={`url(#${clipPathId})`}>
|
||||
<motion.circle
|
||||
animate={{ r: isDark ? 10 : 8 }}
|
||||
transition={{ ease: "easeInOut", duration: 0.35 }}
|
||||
cx="16"
|
||||
cy="16"
|
||||
/>
|
||||
<motion.g
|
||||
animate={{
|
||||
rotate: isDark ? -100 : 0,
|
||||
scale: isDark ? 0.5 : 1,
|
||||
opacity: isDark ? 0 : 1,
|
||||
}}
|
||||
transition={{ ease: "easeInOut", duration: 0.35 }}
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M16 5.5v-4" />
|
||||
<path d="M16 30.5v-4" />
|
||||
<path d="M1.5 16h4" />
|
||||
<path d="M26.5 16h4" />
|
||||
<path d="m23.4 8.6 2.8-2.8" />
|
||||
<path d="m5.7 26.3 2.9-2.9" />
|
||||
<path d="m5.8 5.8 2.8 2.8" />
|
||||
<path d="m23.4 23.4 2.9 2.9" />
|
||||
</motion.g>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////
|
||||
// Backwards compatible export (alias for ThemeToggleButton with default settings)
|
||||
export function ThemeTogglerComponent() {
|
||||
return (
|
||||
<ThemeToggleButton
|
||||
variant="circle"
|
||||
start="top-right"
|
||||
className="size-8"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skiper 26 Theme_buttons_002 — React + CSS + transition view api https://developer.chrome.com/docs/web-platform/view-transitions/
|
||||
* Original concept from rudrodip
|
||||
* Inspired by from https://github.com/rudrodip/theme-toggle-effect
|
||||
* We respect the original creators. This is an inspired rebuild with our own taste and does not claim any ownership.
|
||||
* These animations aren't associated with the rudrodip . They're independent recreations meant to study interaction design
|
||||
*
|
||||
* License & Usage:
|
||||
* - Free to use and modify in both personal and commercial projects.
|
||||
* - Attribution to Skiper UI is required when using the free version.
|
||||
* - No attribution required with Skiper UI Pro.
|
||||
*
|
||||
* Feedback and contributions are welcome.
|
||||
*
|
||||
* Author: @gurvinder-singh02
|
||||
* Website: https://gxuri.in
|
||||
* Twitter: https://x.com/Gur__vi
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue