24 |
25 |
26 | Choose a pattern
27 |
28 |
29 | {patterns.map((p) => (
30 |
48 | ))}
49 |
50 |
51 |
52 |
53 |
54 |
Upload your own
55 |
56 | Recommended: Square (1:1) ratio, with a black and white color
57 | scheme. You can also drag and drop an image here.
58 |
59 |
60 |
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/components/photo-booth.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Copy, Download } from "lucide-react";
4 | import Image from "next/image";
5 | import { useEffect, useState } from "react";
6 | import { LoadingCircle } from "@/components/icons";
7 | import { useParams, useRouter } from "next/navigation";
8 | import va from "@vercel/analytics";
9 |
10 | function forceDownload(blobUrl: string, filename: string) {
11 | let a: any = document.createElement("a");
12 | a.download = filename;
13 | a.href = blobUrl;
14 | document.body.appendChild(a);
15 | a.click();
16 | a.remove();
17 | }
18 |
19 | export default function PhotoBooth({ image }: { image: string | null }) {
20 | const router = useRouter();
21 | const params = useParams();
22 | const { id } = params;
23 | const [copying, setCopying] = useState(false);
24 | const [downloading, setDownloading] = useState(false);
25 |
26 | useEffect(() => {
27 | let interval: NodeJS.Timeout;
28 |
29 | if (!image) {
30 | interval = setInterval(() => {
31 | router.refresh();
32 | }, 1000);
33 | }
34 |
35 | return () => clearInterval(interval);
36 | }, [image, router]);
37 |
38 | return (
39 |
43 | {id && image && (
44 |
45 |
78 |
107 |
108 | )}
109 | {image ? (
110 |
118 | ) : (
119 |
120 |
121 | {id && (
122 |
126 |
127 | This can take anywhere between 20s-30s to run.
128 |
129 |
130 | )}
131 |
132 | )}
133 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/components/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Dispatch, ReactNode, SetStateAction } from "react";
4 | import * as PopoverPrimitive from "@radix-ui/react-popover";
5 | import { Drawer } from "vaul";
6 | import useMediaQuery from "@/lib/hooks/use-media-query";
7 |
8 | export default function Popover({
9 | children,
10 | content,
11 | align = "center",
12 | openPopover,
13 | setOpenPopover,
14 | }: {
15 | children: ReactNode;
16 | content: ReactNode | string;
17 | align?: "center" | "start" | "end";
18 | openPopover: boolean;
19 | setOpenPopover: Dispatch
>;
20 | }) {
21 | const { isMobile } = useMediaQuery();
22 |
23 | if (isMobile) {
24 | return (
25 |
26 | {children}
27 |
28 |
29 |
30 |
33 |
34 | {content}
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | return (
44 |
45 |
46 | {children}
47 |
48 |
49 |
54 | {content}
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/lib/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import Replicate from "replicate";
4 | import { kv } from "@vercel/kv";
5 | import { nanoid } from "./utils";
6 | import { DEFAULT_PATTERN, WEBHOOK_URL } from "./constants";
7 | import { put } from "@vercel/blob";
8 |
9 | const replicate = new Replicate({
10 | auth: process.env.REPLICATE_API_TOKEN as string,
11 | });
12 |
13 | export async function generate(form: FormData) {
14 | const prompt = form.get("prompt") as string;
15 | let patternUrl = form.get("patternUrl") as string;
16 | const patternFile = form.get("patternFile") as File;
17 | if (patternFile.size > 0) {
18 | const response = await put(patternFile.name, patternFile, {
19 | access: "public",
20 | });
21 | patternUrl = response.url;
22 | }
23 |
24 | const id = nanoid();
25 |
26 | const res = await Promise.all([
27 | kv.hset(id, {
28 | prompt,
29 | ...(patternUrl && { pattern: patternUrl }),
30 | }),
31 | replicate.predictions.create({
32 | version:
33 | "75d51a73fce3c00de31ed9ab4358c73e8fc0f627dc8ce975818e653317cb919b",
34 | input: {
35 | prompt,
36 | qr_code_content: "https://spirals.vercel.app",
37 | image: patternUrl,
38 | controlnet_conditioning_scale: 1,
39 | qrcode_background: "white",
40 | },
41 | webhook: `${WEBHOOK_URL}?id=${id}${
42 | process.env.REPLICATE_WEBHOOK_SECRET
43 | ? `&secret=${process.env.REPLICATE_WEBHOOK_SECRET}`
44 | : ""
45 | }`,
46 | webhook_events_filter: ["completed"],
47 | }),
48 | ]);
49 |
50 | console.log(res);
51 |
52 | return id;
53 | }
54 |
--------------------------------------------------------------------------------
/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const WEBHOOK_URL =
2 | process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
3 | ? "https://spirals.vercel.app/api/webhook"
4 | : process.env.NEXT_PUBLIC_VERCEL_ENV === "preview"
5 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}/api/webhook`
6 | : `${process.env.NGROK_URL}/api/webhook`;
7 |
8 | export const DEFAULT_PATTERN =
9 | "https://xd2kcvzsdpeyx1gu.public.blob.vercel-storage.com/8uiaWqu-77Maq6Zn38dfz9iWwXsyaheFfOSJPL.png";
10 |
--------------------------------------------------------------------------------
/lib/hooks/use-enter-submit.ts:
--------------------------------------------------------------------------------
1 | import { useRef, type RefObject } from "react";
2 |
3 | export default function useEnterSubmit(): {
4 | formRef: RefObject;
5 | onKeyDown: (event: React.KeyboardEvent) => void;
6 | } {
7 | const formRef = useRef(null);
8 |
9 | const handleKeyDown = (
10 | event: React.KeyboardEvent,
11 | ): void => {
12 | const value = event.currentTarget.value.trim();
13 | if (
14 | event.key === "Enter" &&
15 | !event.shiftKey &&
16 | !event.nativeEvent.isComposing
17 | ) {
18 | if (value.length === 0) {
19 | event.preventDefault();
20 | return;
21 | }
22 | formRef.current?.requestSubmit();
23 | event.preventDefault();
24 | }
25 | };
26 |
27 | return { formRef, onKeyDown: handleKeyDown };
28 | }
29 |
--------------------------------------------------------------------------------
/lib/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export default function useMediaQuery() {
4 | const [device, setDevice] = useState<"mobile" | "tablet" | "desktop" | null>(
5 | null,
6 | );
7 | const [dimensions, setDimensions] = useState<{
8 | width: number;
9 | height: number;
10 | } | null>(null);
11 |
12 | useEffect(() => {
13 | const checkDevice = () => {
14 | if (window.matchMedia("(max-width: 640px)").matches) {
15 | setDevice("mobile");
16 | } else if (
17 | window.matchMedia("(min-width: 641px) and (max-width: 1024px)").matches
18 | ) {
19 | setDevice("tablet");
20 | } else {
21 | setDevice("desktop");
22 | }
23 | setDimensions({ width: window.innerWidth, height: window.innerHeight });
24 | };
25 |
26 | // Initial detection
27 | checkDevice();
28 |
29 | // Listener for windows resize
30 | window.addEventListener("resize", checkDevice);
31 |
32 | // Cleanup listener
33 | return () => {
34 | window.removeEventListener("resize", checkDevice);
35 | };
36 | }, []);
37 |
38 | return {
39 | device,
40 | width: dimensions?.width,
41 | height: dimensions?.height,
42 | isMobile: device === "mobile",
43 | isTablet: device === "tablet",
44 | isDesktop: device === "desktop",
45 | };
46 | }
47 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import { customAlphabet } from "nanoid";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | // 7-character random string
10 | export const nanoid = customAlphabet(
11 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
12 | 7,
13 | );
14 |
15 | export function nFormatter(num: number, digits?: number) {
16 | if (!num) return "0";
17 | const lookup = [
18 | { value: 1, symbol: "" },
19 | { value: 1e3, symbol: "K" },
20 | { value: 1e6, symbol: "M" },
21 | { value: 1e9, symbol: "G" },
22 | { value: 1e12, symbol: "T" },
23 | { value: 1e15, symbol: "P" },
24 | { value: 1e18, symbol: "E" },
25 | ];
26 | const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
27 | var item = lookup
28 | .slice()
29 | .reverse()
30 | .find(function (item) {
31 | return num >= item.value;
32 | });
33 | return item
34 | ? (num / item.value).toFixed(digits || 1).replace(rx, "$1") + item.symbol
35 | : "0";
36 | }
37 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | serverActions: true,
5 | },
6 | images: {
7 | domains: [
8 | "xd2kcvzsdpeyx1gu.public.blob.vercel-storage.com",
9 | "replicate.delivery",
10 | ],
11 | },
12 | async redirects() {
13 | return [
14 | {
15 | source: "/github",
16 | destination: "https://github.com/steven-tey/spirals",
17 | permanent: false,
18 | },
19 | {
20 | source: "/deploy",
21 | destination: "https://vercel.com/templates/next.js/spirals",
22 | permanent: false,
23 | },
24 | {
25 | source: "/t",
26 | destination: "/",
27 | permanent: false,
28 | },
29 | ];
30 | },
31 | };
32 |
33 | module.exports = nextConfig;
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "spirals",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "format:write": "prettier --write \"**/*.{css,js,json,jsx,ts,tsx}\"",
11 | "format": "prettier \"**/*.{css,js,json,jsx,ts,tsx}\""
12 | },
13 | "dependencies": {
14 | "@radix-ui/react-popover": "^1.0.6",
15 | "@types/node": "20.6.2",
16 | "@types/react": "latest",
17 | "@types/react-dom": "18.2.7",
18 | "@vercel/analytics": "^1.0.2",
19 | "@vercel/blob": "^0.12.4",
20 | "@vercel/kv": "^0.2.3",
21 | "autoprefixer": "10.4.15",
22 | "clsx": "^2.0.0",
23 | "eslint": "8.49.0",
24 | "eslint-config-next": "13.4.19",
25 | "lucide-react": "^0.279.0",
26 | "next": "canary",
27 | "postcss": "8.4.30",
28 | "promptmaker": "^1.1.0",
29 | "react": "latest",
30 | "react-dom": "18.2.0",
31 | "replicate": "^0.18.0",
32 | "sonner": "^1.0.3",
33 | "tailwind-merge": "^1.14.0",
34 | "tailwindcss": "3.3.3",
35 | "typescript": "5.2.2",
36 | "vaul": "^0.6.5"
37 | },
38 | "devDependencies": {
39 | "prettier": "^3.0.3",
40 | "prettier-plugin-tailwindcss": "^0.5.4"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | // prettier.config.js
2 | module.exports = {
3 | bracketSpacing: true,
4 | semi: true,
5 | trailingComma: "all",
6 | printWidth: 80,
7 | tabWidth: 2,
8 | plugins: ["prettier-plugin-tailwindcss"],
9 | };
10 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steven-tey/spirals/098ab7a2c2f0de76a128e24502d11501d8539e74/public/logo.png
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/styles/ClashDisplay-Semibold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/steven-tey/spirals/098ab7a2c2f0de76a128e24502d11501d8539e74/styles/ClashDisplay-Semibold.otf
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | fontFamily: {
12 | display: ["var(--font-clash)"],
13 | default: ["var(--font-inter)", "system-ui", "sans-serif"],
14 | },
15 | animation: {
16 | // Modal
17 | "scale-in": "scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1)",
18 | "fade-in": "fade-in 0.3s ease-out forwards",
19 | // Fade up and down
20 | "fade-up": "fade-up 0.5s",
21 | "fade-down": "fade-down 0.5s",
22 | // Tooltip
23 | "slide-up-fade": "slide-up-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)",
24 | },
25 | keyframes: {
26 | // Modal
27 | "scale-in": {
28 | "0%": { transform: "scale(0.95)" },
29 | "100%": { transform: "scale(1)" },
30 | },
31 | "fade-in": {
32 | "0%": { opacity: "0" },
33 | "100%": { opacity: "1" },
34 | },
35 | // Fade up and down
36 | "fade-up": {
37 | "0%": {
38 | opacity: "0",
39 | transform: "translateY(10px)",
40 | },
41 | "80%": {
42 | opacity: "0.6",
43 | },
44 | "100%": {
45 | opacity: "1",
46 | transform: "translateY(0px)",
47 | },
48 | },
49 | "fade-down": {
50 | "0%": {
51 | opacity: "0",
52 | transform: "translateY(-10px)",
53 | },
54 | "80%": {
55 | opacity: "0.6",
56 | },
57 | "100%": {
58 | opacity: "1",
59 | transform: "translateY(0px)",
60 | },
61 | },
62 | // Tooltip
63 | "slide-up-fade": {
64 | "0%": { opacity: "0", transform: "translateY(2px)" },
65 | "100%": { opacity: "1", transform: "translateY(0)" },
66 | },
67 | },
68 | },
69 | },
70 | plugins: [],
71 | };
72 | export default config;
73 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------