Redesigned the website!
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@vercel/postgres": "^0.9.0",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^0.6.3",
|
||||
@@ -5465,6 +5466,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/canvas-confetti": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz",
|
||||
"integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==",
|
||||
"funding": {
|
||||
"type": "donate",
|
||||
"url": "https://www.paypal.me/kirilvatev"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@vercel/postgres": "^0.9.0",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^0.6.3",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
@@ -33,10 +33,23 @@
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
/* Custom properties */
|
||||
--navigation-height: 3.5rem;
|
||||
--color-one: #ffbd7a;
|
||||
|
||||
--color-two: #fe8bbb;
|
||||
--color-three: #9e7aff;
|
||||
|
||||
/*
|
||||
--color-one: #37ecba;
|
||||
--color-two: #72afd3;
|
||||
--color-three: #ff2e63;
|
||||
*/
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
@@ -63,14 +76,15 @@
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
--color-one: #6aa8e2;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ny } from "@/lib/utils";
|
||||
import GridPattern from "./ui/grid-pattern";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { Button } from "./ui/button";
|
||||
import { Form, FormField, FormItem, FormLabel } from "./ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { releases } from "@/lib/releases";
|
||||
import { addDownload } from "@/lib/db";
|
||||
import { useState } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { ny } from "@/lib/utils";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import Particles from "./ui/particles";
|
||||
import confetti from 'canvas-confetti';
|
||||
import { releases, releaseTree } from "@/lib/releases";
|
||||
|
||||
const BASE_URL = "https://github.com/zen-browser/desktop/releases/latest/download";
|
||||
|
||||
@@ -19,12 +19,10 @@ function getDefaultPlatformBasedOnUserAgent() {
|
||||
userAgent = window.navigator.userAgent;
|
||||
}
|
||||
if (userAgent.includes("Win")) {
|
||||
return "WindowsInstaller";
|
||||
return "Windows";
|
||||
}
|
||||
if (userAgent.includes("Mac")) {
|
||||
// TODO:
|
||||
// return "MacOS";
|
||||
return "";
|
||||
return "MacOS";
|
||||
}
|
||||
if (userAgent.includes("Linux")) {
|
||||
return "Linux";
|
||||
@@ -32,110 +30,350 @@ function getDefaultPlatformBasedOnUserAgent() {
|
||||
return "";
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
platform: z.string().nonempty(),
|
||||
});
|
||||
const field_enter = keyframes`
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
filter: blur(10px);
|
||||
}
|
||||
1% {
|
||||
max-height: 100%;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
`;
|
||||
|
||||
const field_exit = keyframes`
|
||||
from {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
99% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
filter: blur(10px);
|
||||
}
|
||||
100% {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const FormField = styled.div<{ enter: boolean, out: boolean }>`
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
flex-direction: column;
|
||||
margin-top: 3rem;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
animation: 0.2s ease-in-out forwards ${({ enter, out }) => enter ? field_enter : out ? field_exit : ""} !important;
|
||||
animation-delay: ${({ enter }) => enter ? "0.4s" : "0s"};
|
||||
`;
|
||||
|
||||
const FieldTitle = styled.div`
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const FieldDescription = styled.div`
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
export default function DownloadPage() {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
platform: getDefaultPlatformBasedOnUserAgent(),
|
||||
},
|
||||
});
|
||||
const watchRelease = form.watch("platform");
|
||||
const [platform, setPlatform] = useState<string | null>(null);
|
||||
const [architecture, setArchitecture] = useState<string | null>(null);
|
||||
const [windowsDownloadType, setWindowsDownloadType] = useState<string | null>(null);
|
||||
const [linuxDownloadType, setLinuxDownloadType] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
const platform = data.platform;
|
||||
addDownload(platform);
|
||||
console.log("Data: ", data)
|
||||
console.log("Platform: ", platform)
|
||||
console.log("Releases: ", releases)
|
||||
const releasesForPlatform = releases[platform];
|
||||
console.log("Releases for platform: ", releasesForPlatform)
|
||||
const url = `${BASE_URL}/${releasesForPlatform}`;
|
||||
console.log("URL: ", url)
|
||||
window.open(url, "_blank");
|
||||
const [selectedPlatform, setSelectedPlatform] = useState(getDefaultPlatformBasedOnUserAgent());
|
||||
const [selectedArchitecture, setSelectedArchitecture] = useState("64-bit");
|
||||
const [selectedWindowsDownloadType, setSelectedWindowsDownloadType] = useState("installer");
|
||||
const [selectedLinuxDownloadType, setSelectedLinuxDownloadType] = useState("portable");
|
||||
|
||||
const [hasDownloaded, setHasDownloaded] = useState(false);
|
||||
|
||||
const [flowIndex, setFlowIndex] = useState(0);
|
||||
|
||||
const throwConfetti = () => {
|
||||
const end = Date.now() + 3 * 1000 // 3 seconds
|
||||
const colors = ['#a786ff', '#fd8bbc', '#eca184', '#f8deb1']
|
||||
const frame = () => {
|
||||
if (Date.now() > end)
|
||||
return
|
||||
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
startVelocity: 60,
|
||||
origin: { x: 0, y: 0.5 },
|
||||
colors,
|
||||
})
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
startVelocity: 60,
|
||||
origin: { x: 1, y: 0.5 },
|
||||
colors,
|
||||
})
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
frame()
|
||||
}
|
||||
|
||||
const startDownload = () => {
|
||||
const platform = releaseTree[selectedPlatform.toLowerCase()];
|
||||
let arch: string;
|
||||
if (selectedArchitecture === "64-bit") {
|
||||
arch = "x64";
|
||||
} else if (selectedArchitecture === "aarch64") {
|
||||
arch = "arm";
|
||||
} else {
|
||||
arch = "x32";
|
||||
}
|
||||
let releaseTarget: string;
|
||||
if (selectedPlatform === "MacOS") {
|
||||
releaseTarget = platform[arch];
|
||||
} else {
|
||||
releaseTarget = platform[arch][selectedPlatform === "Windows"
|
||||
? windowsDownloadType as string
|
||||
: linuxDownloadType as string];
|
||||
}
|
||||
console.log("Downloading: ");
|
||||
console.log("platform: ", selectedPlatform);
|
||||
console.log("arch: ", arch);
|
||||
setHasDownloaded(true);
|
||||
addDownload(releaseTarget);
|
||||
//window.location.replace(`${BASE_URL}/${releases[releaseTarget]}`);
|
||||
throwConfetti();
|
||||
};
|
||||
|
||||
const continueFlow = () => {
|
||||
if (flowIndex === 0)
|
||||
setPlatform(selectedPlatform);
|
||||
if (flowIndex === 1)
|
||||
setArchitecture(selectedArchitecture);
|
||||
if (flowIndex === 2 || (flowIndex === 1 && platform === "MacOS")) {
|
||||
setWindowsDownloadType(selectedWindowsDownloadType);
|
||||
setLinuxDownloadType(selectedLinuxDownloadType);
|
||||
startDownload();
|
||||
}
|
||||
setFlowIndex(flowIndex + 1);
|
||||
};
|
||||
|
||||
const goBackFlow = () => {
|
||||
if (flowIndex === 1) {
|
||||
setPlatform(null);
|
||||
} else if (flowIndex === 2) {
|
||||
setArchitecture(null);
|
||||
} else if (flowIndex === 3) {
|
||||
setWindowsDownloadType(null);
|
||||
setSelectedWindowsDownloadType("installer");
|
||||
setLinuxDownloadType(null);
|
||||
setSelectedLinuxDownloadType("portable");
|
||||
}
|
||||
if (flowIndex > 0)
|
||||
setFlowIndex(flowIndex - 1);
|
||||
}
|
||||
|
||||
const changeToFlatpak = () => {
|
||||
if (selectedArchitecture === "64-bit") {
|
||||
setSelectedLinuxDownloadType("flatpak");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full relative h-screen flex items-center justify-center flex-col lg:flex-row">
|
||||
<div className="w-full lg:w-1/2 relative h-full px-12 lg:px-24 xl:px-32 2xl:px-64 text-center flex items-cetner justify-center flex-col">
|
||||
<GridPattern
|
||||
numSquares={30}
|
||||
maxOpacity={0.5}
|
||||
height={50}
|
||||
width={50}
|
||||
duration={3}
|
||||
repeatDelay={1}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray="4 2"
|
||||
className={ny(
|
||||
'[mask-image:radial-gradient(350px_circle_at_center,white,transparent)]',
|
||||
'w-full z-0',
|
||||
<>
|
||||
<link rel="stylesheet" type='text/css' href="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/devicon.min.css" />
|
||||
<link rel="stylesheet" type='text/css' href="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/devicon.min.css" />
|
||||
<link rel="stylesheet" type='text/css' href="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/devicon.min.css" />
|
||||
|
||||
<div className="w-full overflow-hidden relative h-screen flex items-center justify-center flex-col lg:flex-row">
|
||||
<div className="flex flex-col justify-center w-full p-10 md:p-0 md:p-20 lg:p-0 lg:w-1/2 2xl:w-1/3 mx-auto">
|
||||
{hasDownloaded && (
|
||||
<div className="flex items-center justify-center flex-col">
|
||||
<h1 className="text-6xl font-bold">Downloaded! ❤️</h1>
|
||||
<p className="text-muted-foreground mt-3">Zen Browser has been downloaded successfully. Enjoy browsing the web with Zen!</p>
|
||||
<div className="flex font-bold mt-5 items-center justify-between mx-auto">
|
||||
<a href="https://github.com/zen-browser">Source Code</a>
|
||||
<a className="ml-5" href="https://patreon.com/zen_browser?utm_medium=unknown&utm_source=join_link&utm_campaign=creatorshare_creator&utm_content=copyLink">Donate</a>
|
||||
<a className="ml-5" href="/release-notes/latest">Release Notes</a>
|
||||
</div>
|
||||
{selectedPlatform === "MacOS" && (
|
||||
<div className="mt-12 flex flex-col items-start border justify-between rounded-md bg-background p-5">
|
||||
<h3 className="text-xl font-semibold">Installation Instructions</h3>
|
||||
<p className="text-muted-foreground text-sm ">To install Zen on MacOS, the process is a bit different. Please follow the instructions below:</p>
|
||||
<Button className="mt-5" onClick={() => window.location.href = "https://github.com/zen-browser/desktop/issues/53"}>Download Zen for MacOS</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) || (
|
||||
<>
|
||||
<h1 className="text-6xl font-bold">Download Zen</h1>
|
||||
<p className="text-muted-foreground mt-3">We are so excited for you to try Zen Browser. But first, we need to know what kind of device you are using. It will be fast, we promise.</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<h1 className="!text-4xl font-bold tracking-[-0.02em] text-black dark:text-white md:text-7xl md:leading-[5rem]">
|
||||
Download Zen Browser
|
||||
</h1>
|
||||
<p className="!text-md text-muted-foreground !font-medium">
|
||||
Get started with Zen Browser today. Get back to browsing the web with peace of mind.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full lg:w-1/2 relative flex flex-col relative items-cetner justify-start">
|
||||
<div className="w-full lg:w-2/3 relative flex flex-col items-center mx-auto mt-10 lg:mt-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="w-2/3 space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="platform"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Select your operating system</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<SelectTrigger className="w-full mb-5">
|
||||
<SelectValue placeholder="Operating System" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Operating System</SelectLabel>
|
||||
<SelectItem value="WindowsInstaller">Windows Installer</SelectItem>
|
||||
<SelectItem value="WindowsZip">Windows (Zip)</SelectItem>
|
||||
<SelectItem value="MacOS">MacOS</SelectItem>
|
||||
<SelectItem value="Linux">Linux</SelectItem>
|
||||
<SelectItem value="WindowsStubInstaller" disabled>Windows Pretty Installer</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Download Zen 🎉</Button>
|
||||
{watchRelease === "Linux" && (
|
||||
<div className="mt-20 rounded border bg-muted p-4 text-muted-foreground">
|
||||
<p>
|
||||
<strong>Linux user?</strong><br/>
|
||||
We{"'"}ve recently added support for flatpak! You can download the flatpak version by running the following command:
|
||||
<pre className="bg-white dark:bg-black p-2 rounded mt-4 overflow-auto">flatpak install flathub io.github.zen_browser.zen</pre>
|
||||
</p>
|
||||
<div className="relative w-full">
|
||||
{platform === null && (
|
||||
<FormField
|
||||
enter={platform === null}
|
||||
out={platform !== null}
|
||||
>
|
||||
<FieldTitle>Platform</FieldTitle>
|
||||
<FieldDescription>Choose the platform you want to download Zen for.</FieldDescription>
|
||||
<div onClick={() => setSelectedPlatform("Windows")} className={ny("select-none mb-2 px-4 py-3 flex items-center rounded-lg bg-background cursor-pointer border", selectedPlatform === "Windows" ? "border-blue-400" : "")}>
|
||||
<Checkbox checked={selectedPlatform === "Windows"} />
|
||||
<i className="devicon-windows8-original ml-3 p-2 border border-blue-400 rounded-lg"></i>
|
||||
<div className="ml-2">Windows</div>
|
||||
</div>
|
||||
)}
|
||||
{watchRelease === "MacOS" && (
|
||||
<div className="mt-20 rounded border bg-muted p-4 text-muted-foreground">
|
||||
<p>
|
||||
<strong>Mac user?</strong><br/>
|
||||
Setting up the MacOS version of Zen Browser is a bit more involved. We{"'"}re working on making this easier, but for now you can follow these steps:
|
||||
<a href="https://github.com/zen-browser/desktop/issues/53" target="_blank" rel="noreferrer">
|
||||
<Button className="mt-5" type="button">View MacOS instructions</Button>
|
||||
</a>
|
||||
</p>
|
||||
<div onClick={() => setSelectedPlatform("Linux")} className={ny("select-none mb-2 px-4 py-3 flex items-center rounded-lg bg-background cursor-pointer border", selectedPlatform === "Linux" ? "border-yellow-400" : "")}>
|
||||
<Checkbox checked={selectedPlatform === "Linux"} />
|
||||
<i className="devicon-linux-plain ml-3 p-2 border border-yellow-400 rounded-lg"></i>
|
||||
<div className="ml-2">Linux</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
<div onClick={() => setSelectedPlatform("MacOS")} className={ny("select-none mb-2 px-4 py-3 flex items-center rounded-lg bg-background cursor-pointer border", selectedPlatform === "MacOS" ? "border-purple-400" : "")}>
|
||||
<Checkbox checked={selectedPlatform === "MacOS"} />
|
||||
<i className="devicon-apple-original p-2 border border-purple-400 ml-3 rounded-lg"></i>
|
||||
<div className="ml-2 font-bold">MacOS</div>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
{/* Architecture */}
|
||||
{((platform === "Windows" || platform === "Linux") && flowIndex === 1) && (
|
||||
<FormField
|
||||
enter={platform === "Windows" || platform === "Linux" && flowIndex === 1}
|
||||
out={platform !== "Windows" && platform !== "Linux" && flowIndex >= 1}
|
||||
>
|
||||
<FieldTitle>Select Architecture</FieldTitle>
|
||||
<FieldDescription>Choose the architecture of your device, either 32-bit or 64-bit.</FieldDescription>
|
||||
<div className="flex items-center justify-center">
|
||||
<div onClick={() => setSelectedArchitecture("64-bit")} className={ny("select-none w-full h-full mb-2 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedArchitecture === "64-bit" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">🚀</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">64 Bits</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">Blazing fast and compatible with modern devices</p>
|
||||
</div>
|
||||
<div onClick={() => setSelectedArchitecture("32-bit")} className={ny("select-none w-full h-full mb-2 ml-10 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedArchitecture === "32-bit" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">👴</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">32 Bits</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">Slow but compatible with older devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
{(platform === "MacOS" && flowIndex === 1) && (
|
||||
<FormField
|
||||
enter={platform === "MacOS"}
|
||||
out={platform !== "MacOS"}
|
||||
>
|
||||
<FieldTitle>Download Zen for MacOS</FieldTitle>
|
||||
<FieldDescription>Click the button below to download Zen for MacOS.</FieldDescription>
|
||||
<div className="flex items-center justify-center">
|
||||
<div onClick={() => setSelectedArchitecture("aarch64")} className={ny("select-none w-full h-full mb-2 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedArchitecture === "aarch64" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">🍏</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">aarch64</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">64-bit ARM architecture, for Apple's M1 or M2 chips</p>
|
||||
</div>
|
||||
<div onClick={() => setSelectedArchitecture("64-bit")} className={ny("select-none w-full h-full mb-2 ml-10 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedArchitecture === "64-bit" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl font-bold my-2 opacity-40 dark:opacity-20">x64</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">Intel</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">64-bit Intel architecture, for older Macs</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
{flowIndex === 2 && (platform === "Windows") && (
|
||||
<FormField
|
||||
enter={platform === "Windows" && flowIndex === 2}
|
||||
out={platform !== "Windows" && flowIndex >= 2}
|
||||
>
|
||||
<FieldTitle
|
||||
className="text-2xl"
|
||||
>Download Zen for Windows {selectedArchitecture}</FieldTitle>
|
||||
<FieldDescription>Choose the type of download you want for Zen for Windows.</FieldDescription>
|
||||
<div className="flex items-center justify-center">
|
||||
<div onClick={() => setSelectedWindowsDownloadType("installer")} className={ny("select-none w-full h-full mb-2 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedWindowsDownloadType === "installer" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">🚀</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">Installer</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">Install Zen with a setup wizard</p>
|
||||
</div>
|
||||
<div onClick={() => setSelectedWindowsDownloadType("portable")} className={ny("select-none w-full h-full mb-2 ml-10 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedWindowsDownloadType === "portable" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">📦</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">Portable</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">Download Zen as a ZIP file</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
{flowIndex === 2 && (platform === "Linux") && (
|
||||
<FormField
|
||||
enter={platform === "Linux" && flowIndex === 2}
|
||||
out={platform !== "Linux" && flowIndex >= 2}
|
||||
>
|
||||
<FieldTitle
|
||||
className="text-2xl"
|
||||
>Download Zen for Linux {selectedArchitecture}</FieldTitle>
|
||||
<FieldDescription>Choose the type of download you want for Zen for Linux.</FieldDescription>
|
||||
<div className="flex items-center justify-center">
|
||||
<div onClick={() => setSelectedLinuxDownloadType("AppImage")} className={ny("select-none w-full h-full mb-2 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedLinuxDownloadType === "AppImage" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">🚀</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">AppImage</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">Install Zen with a setup wizard</p>
|
||||
</div>
|
||||
<div onClick={() => setSelectedLinuxDownloadType("portable")} className={ny("select-none w-full h-full mb-2 ml-5 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedLinuxDownloadType === "portable" ? "border-blue-400" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">📦</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">Portable</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">Download Zen as a ZIP file</p>
|
||||
</div>
|
||||
<div onClick={() => changeToFlatpak()} className={ny("select-none w-full h-full mb-2 ml-5 p-5 flex flex-col items-center rounded-lg bg-background cursor-pointer border", selectedLinuxDownloadType === "flatpak" ? "border-blue-400" : "",
|
||||
selectedArchitecture === "32-bit" ? "opacity-50 cursor-not-allowed" : "")}>
|
||||
<h1 className="text-5xl my-2 opacity-40 dark:opacity-20">🧑💻</h1>
|
||||
<h1 className="text-2xl font-semibold my-2">Flatpak</h1>
|
||||
<p className="text-muted-foreground mx-auto text-center">
|
||||
Install Zen from the Flatpak repository.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
{!hasDownloaded && (
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => goBackFlow()} className={ny("opacity-70", platform === null ? "invisible" : "")}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => continueFlow()} disabled={
|
||||
(selectedPlatform === null)
|
||||
}>
|
||||
{((flowIndex === 1 && platform === "MacOS") || flowIndex === 2) ? "Download 🥳" : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Particles
|
||||
className="absolute inset-0 -z-10 hidden dark:block"
|
||||
quantity={20}
|
||||
ease={70}
|
||||
size={0.05}
|
||||
staticity={70}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<Particles
|
||||
className="absolute inset-0 -z-10 block dark:hidden"
|
||||
quantity={20}
|
||||
ease={70}
|
||||
size={0.05}
|
||||
staticity={70}
|
||||
color="#000000"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import Feature, { FeatureCard } from "./feature";
|
||||
import { Button } from "./ui/button";
|
||||
import TextReveal from "./ui/text-reveal";
|
||||
import styled, { css, keyframes } from "styled-components";
|
||||
import BlurFade from "./ui/blur-fade";
|
||||
|
||||
const profileColors = [
|
||||
"#e8cd7d",
|
||||
@@ -70,31 +71,37 @@ export default function Features() {
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
<TextReveal text="Zen will change the way you browse the web. 🌟" />
|
||||
<Feature
|
||||
title="Split Views"
|
||||
description="View multiple tabs at once. Divide your screen into multiple views and browse multiple websites at the same time."
|
||||
color="#EEDBF9">
|
||||
<img src="/split-view.png" alt="Split Views" className="w-64 h-64 absolute left-1/2 top-1/2" style={{
|
||||
transform: "translate(-50%, -50%)"
|
||||
}} />
|
||||
</Feature>
|
||||
<Feature
|
||||
title="Sidebar"
|
||||
description="Access websites with ease. The sidebar allows you to quickly access your favorite websites without disrupting your browsing experience."
|
||||
color="#F5ED97">
|
||||
<img src="/sidebar.png" alt="Split Views" className="absolute left-1/2 top-1/2 w-4/5 rounded-lg overflow-hidden" style={{
|
||||
transform: "translate(-50%, -50%)"
|
||||
}} />
|
||||
</Feature>
|
||||
<Feature
|
||||
title="Profiles"
|
||||
description="Switch between profiles with ease. Create multiple profiles to keep your work and personal browsing separate."
|
||||
color={currentProfileColor}>
|
||||
<ProfileImage enter={profile1Enter} src="/profile-1.png" alt="Profiles" id="profile-1" className="absolute left-1/2 w-3/4 top-1/2" />
|
||||
<ProfileImage enter={profile2Enter} src="/profile-2.png" alt="Profiles" id="profile-2" className="absolute left-1/2 w-3/4 top-1/2" />
|
||||
<ProfileImage enter={profile3Enter} src="/profile-3.png" alt="Profiles" id="profile-3" className="absolute left-1/2 w-3/4 top-1/2" />
|
||||
</Feature>
|
||||
{/*<TextReveal text="Zen will change the way you browse the web. 🌟" />*/}
|
||||
<BlurFade delay={0.35} inView>
|
||||
<Feature
|
||||
title="Split Views"
|
||||
description="View multiple tabs at once. Divide your screen into multiple views and browse multiple websites at the same time."
|
||||
color="#EEDBF9">
|
||||
<img src="/split-view.png" alt="Split Views" className="w-64 h-64 absolute left-1/2 top-1/2" style={{
|
||||
transform: "translate(-50%, -50%)"
|
||||
}} />
|
||||
</Feature>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.35} inView>
|
||||
<Feature
|
||||
title="Sidebar"
|
||||
description="Access websites with ease. The sidebar allows you to quickly access your favorite websites without disrupting your browsing experience."
|
||||
color="#F5ED97">
|
||||
<img src="/sidebar.png" alt="Split Views" className="absolute left-1/2 top-1/2 w-4/5 rounded-lg overflow-hidden" style={{
|
||||
transform: "translate(-50%, -50%)"
|
||||
}} />
|
||||
</Feature>
|
||||
</BlurFade>
|
||||
<BlurFade delay={0.35} inView>
|
||||
<Feature
|
||||
title="Profiles"
|
||||
description="Switch between profiles with ease. Create multiple profiles to keep your work and personal browsing separate."
|
||||
color={currentProfileColor}>
|
||||
<ProfileImage enter={profile1Enter} src="/profile-1.png" alt="Profiles" id="profile-1" className="absolute left-1/2 w-3/4 top-1/2" />
|
||||
<ProfileImage enter={profile2Enter} src="/profile-2.png" alt="Profiles" id="profile-2" className="absolute left-1/2 w-3/4 top-1/2" />
|
||||
<ProfileImage enter={profile3Enter} src="/profile-3.png" alt="Profiles" id="profile-3" className="absolute left-1/2 w-3/4 top-1/2" />
|
||||
</Feature>
|
||||
</BlurFade>
|
||||
<div className="my-40 w-full flex items-center justify-center flex-col">
|
||||
<h1 className="text-5xl text-center font-bold w-1/2">Want more?</h1>
|
||||
<p className="text-muted-foreground text-center mt-3 w-1/2">Zen Browser is packed with features that will change the way you browse the web. Download it today and experience a new way to browse the web.</p>
|
||||
|
||||
@@ -1,83 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { ny } from "@/lib/utils";
|
||||
import AnimatedGradientText from "./ui/animated-gradient-text";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import WordPullUp from "./ui/word-pull-up";
|
||||
import ShinyButton from "./ui/shiny-button";
|
||||
import GridPattern from "./ui/grid-pattern";
|
||||
import BlurIn from "./ui/blur-in";
|
||||
import { FadeText } from "./ui/fade-text";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { ArrowRightIcon } from '@radix-ui/react-icons'
|
||||
import { useInView } from 'framer-motion'
|
||||
import { useRef } from 'react'
|
||||
import AnimatedGradientText from './ui/animated-gradient-text'
|
||||
import { Button } from './ui/button'
|
||||
import { BorderBeam } from './ui/border-beam'
|
||||
import { ny } from '@/lib/utils'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Particles from './ui/particles'
|
||||
|
||||
const HeaderElement = styled.div`
|
||||
background: light-dark(white, rgba(0, 0, 0, 0.5));
|
||||
`;
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
const ref = useRef(null)
|
||||
const inView = useInView(ref, { once: true, margin: '-100px' })
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-3/4 z-10">
|
||||
<img src="/browser-dark.png" className={ny('hidden dark:block mx-auto shadow-sm border mt-24 z-0 w-4/5 rounded-xl overflow-hidden')} />
|
||||
<img src="/browser-light.png" className={ny('dark:hidden mx-auto shadow-sm border mt-24 z-0 w-4/5 rounded-xl overflow-hidden')} />
|
||||
</div>
|
||||
<HeaderElement className="w-full relative flex h-screen justify-center flex-col align-center border-b">
|
||||
<GridPattern
|
||||
numSquares={30}
|
||||
maxOpacity={0.5}
|
||||
height={50}
|
||||
width={50}
|
||||
duration={3}
|
||||
repeatDelay={1}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray="4 2"
|
||||
className={ny(
|
||||
'[mask-image:radial-gradient(350px_circle_at_center,white,transparent)]',
|
||||
'w-full z-0',
|
||||
)}
|
||||
/>
|
||||
<div className="z-10 flex mb-10 items-center justify-center">
|
||||
<a href="/download">
|
||||
<AnimatedGradientText>
|
||||
🎉
|
||||
{' '}
|
||||
<hr className="mx-2 h-4 w-[1px] shrink-0 bg-gray-300" />
|
||||
{' '}
|
||||
<span
|
||||
className={ny(
|
||||
`inline animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
)}
|
||||
>
|
||||
Introducing Zen Alpha
|
||||
</span>
|
||||
<ChevronRight className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" />
|
||||
</AnimatedGradientText>
|
||||
<section
|
||||
id="hero"
|
||||
className="relative mx-auto mt-40 max-w-7xl px-6 text-center md:px-8"
|
||||
>
|
||||
<a href="/download">
|
||||
<AnimatedGradientText>
|
||||
🎉
|
||||
{' '}
|
||||
<hr className="mx-2 h-4 w-[1px] shrink-0 bg-gray-300" />
|
||||
{' '}
|
||||
<span
|
||||
className={ny(
|
||||
`inline animate-gradient bg-gradient-to-r from-[#ffaa40] via-[#9c40ff] to-[#ffaa40] bg-[length:var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
)}
|
||||
>
|
||||
Introducing Zen Alpha
|
||||
</span>
|
||||
<ChevronRight className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" />
|
||||
</AnimatedGradientText>
|
||||
</a>
|
||||
</div>
|
||||
<WordPullUp
|
||||
className="text-3xl font-bold tracking-[-0.02em] text-black dark:text-white md:text-7xl md:leading-[5rem]"
|
||||
words="Make It Yours"
|
||||
/>
|
||||
<BlurIn
|
||||
word="Firefox based browser with a focus on privacy and customization."
|
||||
className="!text-xl text-muted-foreground !font-medium"
|
||||
/>
|
||||
<div className="max-w-1/4 mt-10 flex items-center justify-center">
|
||||
<a href="/release-notes" className="mr-5">
|
||||
<FadeText
|
||||
className="text-md font-medium text-black dark:text-white"
|
||||
direction="up"
|
||||
framerProps={{
|
||||
show: { transition: { delay: 0.2 } },
|
||||
}}
|
||||
text="Release Notes"
|
||||
/>
|
||||
</a>
|
||||
<a href="/download">
|
||||
<ShinyButton text="Download now" />
|
||||
</a>
|
||||
</div>
|
||||
</HeaderElement>
|
||||
<h1 className="animate-fade-in -translate-y-4 text-balance bg-gradient-to-br from-black from-30% to-black/40 bg-clip-text py-6 text-5xl font-semibold leading-none tracking-tighter text-transparent opacity-0 [--animation-delay:200ms] sm:text-6xl md:text-7xl lg:text-8xl dark:from-white dark:to-white/40">
|
||||
Zen is the best way
|
||||
<br className="hidden md:block" />
|
||||
{' '}
|
||||
to browse the web.
|
||||
</h1>
|
||||
<p className="animate-fade-in mb-12 -translate-y-4 text-balance text-lg tracking-tight text-gray-400 opacity-0 [--animation-delay:400ms] md:text-xl">
|
||||
Beautifully designed, privacy-focused, and packed with features.
|
||||
<br className="hidden md:block" />
|
||||
{' '}
|
||||
We care about your experience, not your data.
|
||||
</p>
|
||||
<Button className="animate-fade-in -translate-y-4 gap-1 rounded-lg text-white opacity-0 ease-in-out [--animation-delay:600ms] dark:text-black" onClick={() => window.location.href = '/download'}>
|
||||
<span>Download Zen Now </span>
|
||||
<ArrowRightIcon className="ml-1 size-4 transition-transform duration-300 ease-in-out group-hover:translate-x-1" />
|
||||
</Button>
|
||||
<div
|
||||
ref={ref}
|
||||
className="animate-fade-up relative mt-32 opacity-0 [--animation-delay:400ms] [perspective:2000px] after:absolute after:inset-0 after:z-50 after:[background:linear-gradient(to_top,hsl(var(--background))_30%,transparent)]"
|
||||
>
|
||||
<div
|
||||
className={`rounded-xl border border-white/10 bg-white bg-opacity-[0.01] before:absolute before:bottom-1/2 before:left-0 before:top-0 before:size-full before:opacity-0 before:[background-image:linear-gradient(to_bottom,var(--color-one),var(--color-one),transparent_40%)] before:[filter:blur(180px)] ${
|
||||
inView ? 'before:animate-image-glow' : ''
|
||||
}`}
|
||||
>
|
||||
<BorderBeam
|
||||
size={200}
|
||||
duration={12}
|
||||
delay={11}
|
||||
colorFrom="var(--color-one)"
|
||||
colorTo="var(--color-two)"
|
||||
/>
|
||||
|
||||
<img
|
||||
src="/browser-dark.png"
|
||||
alt="browser Image"
|
||||
className="relative hidden size-full rounded-[inherit] border object-contain dark:block"
|
||||
/>
|
||||
<img
|
||||
src="/browser-light.png"
|
||||
alt="browser Image"
|
||||
className="relative block size-full rounded-[inherit] border object-contain dark:hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Particles
|
||||
className="absolute inset-0 -z-10 hidden dark:block"
|
||||
quantity={50}
|
||||
ease={70}
|
||||
size={0.05}
|
||||
staticity={40}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<Particles
|
||||
className="absolute inset-0 -z-10 block dark:hidden"
|
||||
quantity={50}
|
||||
ease={70}
|
||||
size={0.05}
|
||||
staticity={40}
|
||||
color="#000000"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
60
src/components/ui/blur-fade.tsx
Normal file
60
src/components/ui/blur-fade.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import type { Variants } from 'framer-motion'
|
||||
import { AnimatePresence, motion, useInView } from 'framer-motion'
|
||||
|
||||
interface BlurFadeProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: {
|
||||
hidden: { y: number }
|
||||
visible: { y: number }
|
||||
}
|
||||
duration?: number
|
||||
delay?: number
|
||||
yOffset?: number
|
||||
inView?: boolean
|
||||
inViewMargin?: string
|
||||
blur?: string
|
||||
}
|
||||
|
||||
export default function BlurFade({
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
duration = 0.4,
|
||||
delay = 0,
|
||||
yOffset = 6,
|
||||
inView = false,
|
||||
inViewMargin = '-50px',
|
||||
blur = '6px',
|
||||
}: BlurFadeProps) {
|
||||
const ref = useRef(null)
|
||||
const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
|
||||
const isInView = !inView || inViewResult
|
||||
const defaultVariants: Variants = {
|
||||
hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` },
|
||||
visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` },
|
||||
}
|
||||
const combinedVariants = variant || defaultVariants
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={combinedVariants}
|
||||
transition={{
|
||||
delay: 0.04 + delay,
|
||||
duration,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
49
src/components/ui/border-beam.tsx
Normal file
49
src/components/ui/border-beam.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ny } from '@/lib/utils'
|
||||
|
||||
interface BorderBeamProps {
|
||||
className?: string
|
||||
size?: number
|
||||
duration?: number
|
||||
borderWidth?: number
|
||||
anchor?: number
|
||||
colorFrom?: string
|
||||
colorTo?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function BorderBeam({
|
||||
className,
|
||||
size = 200,
|
||||
duration = 15,
|
||||
anchor = 90,
|
||||
borderWidth = 1.5,
|
||||
colorFrom = '#ffaa40',
|
||||
colorTo = '#9c40ff',
|
||||
delay = 0,
|
||||
}: BorderBeamProps) {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--size': size,
|
||||
'--duration': duration,
|
||||
'--anchor': anchor,
|
||||
'--border-width': borderWidth,
|
||||
'--color-from': colorFrom,
|
||||
'--color-to': colorTo,
|
||||
'--delay': `-${delay}s`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={ny(
|
||||
'pointer-events-none absolute inset-0 rounded-[inherit] [border:calc(var(--border-width)*1px)_solid_transparent]',
|
||||
|
||||
// mask styles
|
||||
'![mask-clip:padding-box,border-box] ![mask-composite:intersect] [mask:linear-gradient(transparent,transparent),linear-gradient(white,white)]',
|
||||
|
||||
// pseudo styles
|
||||
'after:animate-border-beam after:absolute after:aspect-square after:w-[calc(var(--size)*1px)] after:[animation-delay:var(--delay)] after:[background:linear-gradient(to_left,var(--color-from),var(--color-to),transparent)] after:[offset-anchor:calc(var(--anchor)*1%)_50%] after:[offset-path:rect(0_auto_auto_0_round_calc(var(--size)*1px))]',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
120
src/components/ui/confetti.tsx
Normal file
120
src/components/ui/confetti.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
import confetti from 'canvas-confetti'
|
||||
import type { ReactNode } from 'react'
|
||||
import React, { createContext, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
|
||||
import type {
|
||||
GlobalOptions as ConfettiGlobalOptions,
|
||||
CreateTypes as ConfettiInstance,
|
||||
Options as ConfettiOptions,
|
||||
} from 'canvas-confetti'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import type { ButtonProps } from '~/components/ui/button'
|
||||
|
||||
interface Api {
|
||||
fire: (options?: ConfettiOptions) => void
|
||||
}
|
||||
|
||||
type Props = React.ComponentPropsWithRef<'canvas'> & {
|
||||
options?: ConfettiOptions
|
||||
globalOptions?: ConfettiGlobalOptions
|
||||
manualstart?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export type ConfettiRef = Api | null
|
||||
|
||||
const ConfettiContext = createContext<Api>({} as Api)
|
||||
|
||||
const Confetti = forwardRef<ConfettiRef, Props>((props, ref) => {
|
||||
const {
|
||||
options,
|
||||
globalOptions = { resize: true, useWorker: true },
|
||||
manualstart = false,
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
const instanceRef = useRef<ConfettiInstance | null>(null) // confetti instance
|
||||
|
||||
const canvasRef = useCallback(
|
||||
// https://react.dev/reference/react-dom/components/common#ref-callback
|
||||
// https://reactjs.org/docs/refs-and-the-dom.html#callback-refs
|
||||
(node: HTMLCanvasElement) => {
|
||||
if (node !== null) {
|
||||
// <canvas> is mounted => create the confetti instance
|
||||
if (instanceRef.current)
|
||||
return // if not already created
|
||||
instanceRef.current = confetti.create(node, {
|
||||
...globalOptions,
|
||||
resize: true,
|
||||
})
|
||||
}
|
||||
else {
|
||||
// <canvas> is unmounted => reset and destroy instanceRef
|
||||
if (instanceRef.current) {
|
||||
instanceRef.current.reset()
|
||||
instanceRef.current = null
|
||||
}
|
||||
}
|
||||
},
|
||||
[globalOptions],
|
||||
)
|
||||
|
||||
// `fire` is a function that calls the instance() with `opts` merged with `options`
|
||||
const fire = useCallback(
|
||||
(opts = {}) => instanceRef.current?.({ ...options, ...opts }),
|
||||
[options],
|
||||
)
|
||||
|
||||
const api = useMemo(
|
||||
() => ({
|
||||
fire,
|
||||
}),
|
||||
[fire],
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => api, [api])
|
||||
|
||||
useEffect(() => {
|
||||
if (!manualstart)
|
||||
fire()
|
||||
}, [manualstart, fire])
|
||||
|
||||
return (
|
||||
<ConfettiContext.Provider value={api}>
|
||||
<canvas ref={canvasRef} {...rest} />
|
||||
{children}
|
||||
</ConfettiContext.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
interface ConfettiButtonProps extends ButtonProps {
|
||||
options?: ConfettiOptions &
|
||||
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function ConfettiButton({ options, children, ...props }: ConfettiButtonProps) {
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = rect.left + rect.width / 2
|
||||
const y = rect.top + rect.height / 2
|
||||
confetti({
|
||||
...options,
|
||||
origin: {
|
||||
x: x / window.innerWidth,
|
||||
y: y / window.innerHeight,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Confetti, ConfettiButton }
|
||||
|
||||
export default Confetti
|
||||
271
src/components/ui/particles.tsx
Normal file
271
src/components/ui/particles.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface MousePosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function MousePosition(): MousePosition {
|
||||
const [mousePosition, setMousePosition] = useState<MousePosition>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
setMousePosition({ x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return mousePosition
|
||||
}
|
||||
|
||||
interface ParticlesProps {
|
||||
className?: string
|
||||
quantity?: number
|
||||
staticity?: number
|
||||
ease?: number
|
||||
size?: number
|
||||
refresh?: boolean
|
||||
color?: string
|
||||
vx?: number
|
||||
vy?: number
|
||||
}
|
||||
function hexToRgb(hex: string): number[] {
|
||||
hex = hex.replace('#', '')
|
||||
const hexInt = Number.parseInt(hex, 16)
|
||||
const red = (hexInt >> 16) & 255
|
||||
const green = (hexInt >> 8) & 255
|
||||
const blue = hexInt & 255
|
||||
return [red, green, blue]
|
||||
}
|
||||
|
||||
const Particles: React.FC<ParticlesProps> = ({
|
||||
className = '',
|
||||
quantity = 100,
|
||||
staticity = 50,
|
||||
ease = 50,
|
||||
size = 0.4,
|
||||
refresh = false,
|
||||
color = '#ffffff',
|
||||
vx = 0,
|
||||
vy = 0,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
const context = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const circles = useRef<any[]>([])
|
||||
const mousePosition = MousePosition()
|
||||
const mouse = useRef<{ x: number, y: number }>({ x: 0, y: 0 })
|
||||
const canvasSize = useRef<{ w: number, h: number }>({ w: 0, h: 0 })
|
||||
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 1
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
context.current = canvasRef.current.getContext('2d')
|
||||
}
|
||||
initCanvas()
|
||||
animate()
|
||||
window.addEventListener('resize', initCanvas)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', initCanvas)
|
||||
}
|
||||
}, [color])
|
||||
|
||||
useEffect(() => {
|
||||
onMouseMove()
|
||||
}, [mousePosition.x, mousePosition.y])
|
||||
|
||||
useEffect(() => {
|
||||
initCanvas()
|
||||
}, [refresh])
|
||||
|
||||
const initCanvas = () => {
|
||||
resizeCanvas()
|
||||
drawParticles()
|
||||
}
|
||||
|
||||
const onMouseMove = () => {
|
||||
if (canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect()
|
||||
const { w, h } = canvasSize.current
|
||||
const x = mousePosition.x - rect.left - w / 2
|
||||
const y = mousePosition.y - rect.top - h / 2
|
||||
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
|
||||
if (inside) {
|
||||
mouse.current.x = x
|
||||
mouse.current.y = y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Circle {
|
||||
x: number
|
||||
y: number
|
||||
translateX: number
|
||||
translateY: number
|
||||
size: number
|
||||
alpha: number
|
||||
targetAlpha: number
|
||||
dx: number
|
||||
dy: number
|
||||
magnetism: number
|
||||
}
|
||||
|
||||
const resizeCanvas = () => {
|
||||
if (canvasContainerRef.current && canvasRef.current && context.current) {
|
||||
circles.current.length = 0
|
||||
canvasSize.current.w = canvasContainerRef.current.offsetWidth
|
||||
canvasSize.current.h = canvasContainerRef.current.offsetHeight
|
||||
canvasRef.current.width = canvasSize.current.w * dpr
|
||||
canvasRef.current.height = canvasSize.current.h * dpr
|
||||
canvasRef.current.style.width = `${canvasSize.current.w}px`
|
||||
canvasRef.current.style.height = `${canvasSize.current.h}px`
|
||||
context.current.scale(dpr, dpr)
|
||||
}
|
||||
}
|
||||
|
||||
const circleParams = (): Circle => {
|
||||
const x = Math.floor(Math.random() * canvasSize.current.w)
|
||||
const y = Math.floor(Math.random() * canvasSize.current.h)
|
||||
const translateX = 0
|
||||
const translateY = 0
|
||||
const pSize = Math.floor(Math.random() * 2) + size
|
||||
const alpha = 0
|
||||
const targetAlpha = Number.parseFloat((Math.random() * 0.6 + 0.1).toFixed(1))
|
||||
const dx = (Math.random() - 0.5) * 0.1
|
||||
const dy = (Math.random() - 0.5) * 0.1
|
||||
const magnetism = 0.1 + Math.random() * 4
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
translateX,
|
||||
translateY,
|
||||
size: pSize,
|
||||
alpha,
|
||||
targetAlpha,
|
||||
dx,
|
||||
dy,
|
||||
magnetism,
|
||||
}
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(color)
|
||||
|
||||
const drawCircle = (circle: Circle, update = false) => {
|
||||
if (context.current) {
|
||||
const { x, y, translateX, translateY, size, alpha } = circle
|
||||
context.current.translate(translateX, translateY)
|
||||
context.current.beginPath()
|
||||
context.current.arc(x, y, size, 0, 2 * Math.PI)
|
||||
context.current.fillStyle = `rgba(${rgb.join(', ')}, ${alpha})`
|
||||
context.current.fill()
|
||||
context.current.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
|
||||
if (!update) {
|
||||
circles.current.push(circle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
if (context.current) {
|
||||
context.current.clearRect(
|
||||
0,
|
||||
0,
|
||||
canvasSize.current.w,
|
||||
canvasSize.current.h,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const drawParticles = () => {
|
||||
clearContext()
|
||||
const particleCount = quantity
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const circle = circleParams()
|
||||
drawCircle(circle)
|
||||
}
|
||||
}
|
||||
|
||||
const remapValue = (
|
||||
value: number,
|
||||
start1: number,
|
||||
end1: number,
|
||||
start2: number,
|
||||
end2: number,
|
||||
): number => {
|
||||
const remapped
|
||||
= ((value - start1) * (end2 - start2)) / (end1 - start1) + start2
|
||||
return remapped > 0 ? remapped : 0
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
clearContext()
|
||||
circles.current.forEach((circle: Circle, i: number) => {
|
||||
// Handle the alpha value
|
||||
const edge = [
|
||||
circle.x + circle.translateX - circle.size, // distance from left edge
|
||||
canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge
|
||||
circle.y + circle.translateY - circle.size, // distance from top edge
|
||||
canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge
|
||||
]
|
||||
const closestEdge = edge.reduce((a, b) => Math.min(a, b))
|
||||
const remapClosestEdge = Number.parseFloat(
|
||||
remapValue(closestEdge, 0, 20, 0, 1).toFixed(2),
|
||||
)
|
||||
if (remapClosestEdge > 1) {
|
||||
circle.alpha += 0.02
|
||||
if (circle.alpha > circle.targetAlpha) {
|
||||
circle.alpha = circle.targetAlpha
|
||||
}
|
||||
}
|
||||
else {
|
||||
circle.alpha = circle.targetAlpha * remapClosestEdge
|
||||
}
|
||||
circle.x += circle.dx + vx
|
||||
circle.y += circle.dy + vy
|
||||
circle.translateX
|
||||
+= (mouse.current.x / (staticity / circle.magnetism) - circle.translateX)
|
||||
/ ease
|
||||
circle.translateY
|
||||
+= (mouse.current.y / (staticity / circle.magnetism) - circle.translateY)
|
||||
/ ease
|
||||
|
||||
drawCircle(circle, true)
|
||||
|
||||
// circle gets out of the canvas
|
||||
if (
|
||||
circle.x < -circle.size
|
||||
|| circle.x > canvasSize.current.w + circle.size
|
||||
|| circle.y < -circle.size
|
||||
|| circle.y > canvasSize.current.h + circle.size
|
||||
) {
|
||||
// remove the circle from the array
|
||||
circles.current.splice(i, 1)
|
||||
// create a new circle
|
||||
const newCircle = circleParams()
|
||||
drawCircle(newCircle)
|
||||
// update the circle position
|
||||
}
|
||||
})
|
||||
window.requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} ref={canvasContainerRef} aria-hidden="true">
|
||||
<canvas ref={canvasRef} className="size-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Particles
|
||||
@@ -1,7 +1,46 @@
|
||||
export const releases: any = {
|
||||
WindowsInstaller: "zen.installer.exe",
|
||||
WindowsStubInstaller: "zen.installer.pretty.exe",
|
||||
WindowsZip: "zen.win64.zip",
|
||||
MacOS: "zen.macos.dmg",
|
||||
Linux: "zen.linux.tar.bz2",
|
||||
};
|
||||
export const releases: any = {
|
||||
WindowsInstaller: "zen.installer-x64.exe",
|
||||
WindowsInstaller32: "zen.installer-x32.exe",
|
||||
|
||||
WindowsZip: "zen.win-x64.zip",
|
||||
WindowsZip32: "zen.win-x32.zip",
|
||||
|
||||
MacOS: "zen.macos-aarch64.dmg",
|
||||
MacOSIntel: "zen.macos-x64.dmg",
|
||||
|
||||
Linux: "zen.linux-x64.tar.bz2",
|
||||
Linux32: "zen.linux-x32.tar.bz2",
|
||||
|
||||
LinuxAppImage: "zen-x64.AppImage",
|
||||
LinuxAppImage32: "zen-x32.AppImage",
|
||||
};
|
||||
|
||||
// platform
|
||||
// -> arch
|
||||
// -> file
|
||||
export const releaseTree: any = {
|
||||
windows: {
|
||||
x64: {
|
||||
installer: "WindowsInstaller",
|
||||
portable: "WindowsZip",
|
||||
},
|
||||
x32: {
|
||||
installer: "WindowsInstaller32",
|
||||
portable: "WindowsZip32",
|
||||
},
|
||||
},
|
||||
macos: {
|
||||
x64: "MacOSIntel",
|
||||
arm: "MacOS",
|
||||
},
|
||||
linux: {
|
||||
x64: {
|
||||
portable: "Linux",
|
||||
appimage: "LinuxAppImage",
|
||||
},
|
||||
x32: {
|
||||
portable: "Linux32",
|
||||
appimage: "LinuxAppImage32",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Config } from "tailwindcss"
|
||||
import { fontFamily } from 'tailwindcss/defaultTheme'
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
@@ -12,81 +13,117 @@ const config = {
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', ...fontFamily.sans],
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
grid: {
|
||||
"0%": { transform: "translateY(-50%)" },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
},
|
||||
"shine-pulse": {
|
||||
"0%": {
|
||||
"background-position": "0% 0%",
|
||||
},
|
||||
"50%": {
|
||||
"background-position": "100% 100%",
|
||||
},
|
||||
to: {
|
||||
"background-position": "0% 0%",
|
||||
},
|
||||
},
|
||||
'accordion-down': {
|
||||
from: { height: '0' },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: '0' },
|
||||
},
|
||||
'border-beam': {
|
||||
'100%': {
|
||||
'offset-distance': '100%',
|
||||
},
|
||||
},
|
||||
'image-glow': {
|
||||
'0%': {
|
||||
'opacity': '0',
|
||||
'animation-timing-function': 'cubic-bezier(0.74, 0.25, 0.76, 1)',
|
||||
},
|
||||
'10%': {
|
||||
'opacity': '0.7',
|
||||
'animation-timing-function': 'cubic-bezier(0.12, 0.01, 0.08, 0.99)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '0.4',
|
||||
},
|
||||
},
|
||||
'fade-in': {
|
||||
from: { opacity: '0', transform: 'translateY(-10px)' },
|
||||
to: { opacity: '1', transform: 'none' },
|
||||
},
|
||||
'fade-up': {
|
||||
from: { opacity: '0', transform: 'translateY(20px)' },
|
||||
to: { opacity: '1', transform: 'none' },
|
||||
},
|
||||
'shimmer': {
|
||||
'0%, 90%, 100%': {
|
||||
'background-position': 'calc(-100% - var(--shimmer-width)) 0',
|
||||
},
|
||||
'30%, 60%': {
|
||||
'background-position': 'calc(100% + var(--shimmer-width)) 0',
|
||||
},
|
||||
},
|
||||
'marquee': {
|
||||
from: { transform: 'translateX(0)' },
|
||||
to: { transform: 'translateX(calc(-100% - var(--gap)))' },
|
||||
},
|
||||
'marquee-vertical': {
|
||||
from: { transform: 'translateY(0)' },
|
||||
to: { transform: 'translateY(calc(-100% - var(--gap)))' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
grid: "grid 15s linear infinite",
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'border-beam': 'border-beam calc(var(--duration)*1s) infinite linear',
|
||||
'image-glow': 'image-glow 4100ms 600ms ease-out forwards',
|
||||
'fade-in': 'fade-in 1000ms var(--animation-delay, 0ms) ease forwards',
|
||||
'fade-up': 'fade-up 1000ms var(--animation-delay, 0ms) ease forwards',
|
||||
'shimmer': 'shimmer 8s infinite',
|
||||
'marquee': 'marquee var(--duration) infinite linear',
|
||||
'marquee-vertical': 'marquee-vertical var(--duration) linear infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user