Improves password prompt UI and features

This commit is contained in:
oiov
2025-04-07 21:34:07 +08:00
parent 14e66ae8ae
commit 7aac181b51
9 changed files with 304 additions and 182 deletions

View File

@@ -7,9 +7,10 @@
## 功能
- 🔗 **短链生成**:生成附有访问者统计信息的短链接 (支持密码保护)
- 📮 **临时邮箱**:创建多个临时邮箱接收和发送邮件
- 🌐 **多租户支持**:无缝管理多个 DNS 记录
-**即时记录创建**:快速设置 CNAME、A 等记录
- 🔗 **短链生成**:生成附有访问者统计信息的短链接
- 📸 **截图 API**:访问截图 API
- <20> **元数据抓取 API**:访问元数据抓取 API
- <20>😀 **权限管理**:方便审核的管理员面板

View File

@@ -7,10 +7,10 @@
## Features
- 🔗 **URL Shortening:** Generate short links with visitor analytic and password
- 📮 **Email Support:** Receive emails and send emails
- 🌐 **Multi-Tenant Support:** Manage multiple DNS records seamlessly
-**Instant Record Creation:** Set up CNAME, A, and other records quickly
- 🔗 **URL Shortening:** Generate short links with visitor statistics attached
- 📸 **Screenshot API:** Access to screenshot API.
- 💯 **Meta Scraping API:** Access to meta scraping API.
- 😀 **Permission Management:** A convenient admin panel for auditing

View File

@@ -0,0 +1,174 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spotlight } from "@/components/ui/spotlight";
import { Icons } from "@/components/shared/icons";
export default function PasswordPrompt() {
const router = useRouter();
const searchParams = useSearchParams();
const slug = searchParams.get("slug");
const initialPassword = searchParams.get("password") || "";
const isError = searchParams.get("error") === "1";
const [password, setPassword] = useState(["", "", "", "", "", ""]);
const [isHidden, setIsHidden] = useState(true);
const [isPending, startTransition] = useTransition();
const inputRefs = useRef<(HTMLInputElement | null)[]>(Array(6).fill(null));
useEffect(() => {
if (initialPassword) {
const paddedPassword = initialPassword
.padEnd(6, "")
.split("")
.slice(0, 6);
setPassword(paddedPassword);
handleSubmit(new Event("submit") as any);
}
}, [initialPassword]);
const handleChange = (index: number, value: string) => {
if (value.length > 1) return;
const newPassword = [...password];
newPassword[index] = value;
setPassword(newPassword);
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === "Backspace" && !password[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
} else if (e.key === "Enter") {
handleSubmit(e as any);
}
};
const handleSubmit = (e: React.FormEvent) => {
startTransition(async () => {
e.preventDefault();
const fullPassword = password.join("");
if (slug && !isPending && fullPassword.length === 6) {
router.push(`/s/${slug}?password=${encodeURIComponent(fullPassword)}`);
}
});
};
const toggleVisibility = () => {
setIsHidden(!isHidden);
};
return (
<div className="relative flex min-h-screen flex-col bg-neutral-900">
<div
className={cn(
"pointer-events-none absolute inset-0 select-none [background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#1e1e1e_1px,transparent_1px),linear-gradient(to_bottom,#1e1e1e_1px,transparent_1px)]",
)}
/>
<Spotlight
className="-top-40 left-0 md:-top-20 md:left-60"
fill="white"
/>
<div className="flex flex-1 items-center justify-center">
<div className="mx-3 w-full max-w-md rounded-lg bg-black/70 px-6 py-6 shadow-md shadow-neutral-900 backdrop-blur-xl md:px-[50px]">
<h1 className="mb-4 flex items-center justify-center gap-2 text-center text-2xl font-bold text-neutral-50">
Protected Link
</h1>
<div className="mb-4 break-all text-left text-sm text-neutral-400">
<p>
You are attempting to access a password-protected link.{" "}
<strong>Please contact the owner to get the password.</strong>{" "}
Learn more about this from our{" "}
<Link
className="underline"
target="_blank"
href="/docs/short-urls#password"
>
docs
</Link>
.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="flex justify-between gap-2">
{password.map((char, index) => (
<Input
key={index}
type={isHidden ? "password" : "text"}
value={char}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
ref={(el) => (inputRefs.current[index] = el as any)}
maxLength={1}
autoFocus={index === 0}
className="h-12 w-12 rounded-md border border-gray-300 text-center text-lg font-medium text-neutral-100 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
))}
</div>
{isError && (
<p className="mb-2 animate-fade-in text-left text-sm text-red-500">
Incorrect password. Please try again.
</p>
)}
<div className="flex items-center justify-between">
<button
type="button"
onClick={toggleVisibility}
className="flex items-center gap-1 text-neutral-400 transition-colors hover:text-neutral-800"
>
{isHidden ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
<Button
type="submit"
variant={"default"}
className="flex items-center gap-2"
disabled={
!(slug && !isPending && password.join("").length === 6)
}
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<Icons.unLock className="size-4" />
)}
{isPending ? "Unlocking..." : "Unlock"}
</Button>
</div>
</form>
</div>
</div>
<footer className="py-4 text-center text-sm text-muted-foreground">
Powered by{" "}
<Link
className="hover:underline"
href={"https://wr.do"}
target="_blank"
style={{ fontFamily: "Bahamas Bold" }}
>
{siteConfig.name}
</Link>
</footer>
</div>
);
}

View File

@@ -5,29 +5,9 @@ interface ProtectedLayoutProps {
}
export default async function Password({ children }: ProtectedLayoutProps) {
// const filteredLinks = sidebarLinks.map((section) => ({
// ...section,
// items: section.items.filter(
// ({ authorizeOnly }) => !authorizeOnly || authorizeOnly === user.role,
// ),
// }));
return (
<div className="relative flex h-screen w-full overflow-hidden">
<div className="flex flex-1 flex-col">
{/* <header className="sticky top-0 z-50 flex h-14 border-b bg-background px-4 lg:h-[60px] xl:px-8">
<MaxWidthWrapper className="flex max-w-7xl items-center gap-x-3 px-0">
<MobileSheetSidebar links={filteredLinks} />
<div className="w-full flex-1">
<SearchCommand links={filteredLinks} />
</div>
<ModeToggle />
<UserAccountNav />
</MaxWidthWrapper>
</header> */}
<main className="flex-1">
<MaxWidthWrapper className="flex max-h-screen max-w-full flex-col gap-4 px-0 lg:gap-6">
{children}

View File

@@ -1,163 +1,12 @@
"use client";
import { constructMetadata } from "@/lib/utils";
import { useEffect, useRef, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
import PasswordPrompt from "./card";
import { siteConfig } from "@/config/site";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Icons } from "@/components/shared/icons";
export const metadata = constructMetadata({
title: "Password Required",
description: "Short link with password",
});
export default function PasswordPrompt() {
const router = useRouter();
const searchParams = useSearchParams();
const slug = searchParams.get("slug");
const initialPassword = searchParams.get("password") || "";
const isError = searchParams.get("error") === "1";
const [password, setPassword] = useState(["", "", "", "", "", ""]);
const [isHidden, setIsHidden] = useState(true);
const [isPending, startTransition] = useTransition();
const inputRefs = useRef<(HTMLInputElement | null)[]>(Array(6).fill(null));
useEffect(() => {
if (initialPassword) {
const paddedPassword = initialPassword
.padEnd(6, "")
.split("")
.slice(0, 6);
setPassword(paddedPassword);
handleSubmit(new Event("submit") as any);
}
}, [initialPassword]);
const handleChange = (index: number, value: string) => {
if (value.length > 1) return;
const newPassword = [...password];
newPassword[index] = value;
setPassword(newPassword);
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === "Backspace" && !password[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
} else if (e.key === "Enter") {
handleSubmit(e as any);
}
};
const handleSubmit = (e: React.FormEvent) => {
startTransition(async () => {
e.preventDefault();
const fullPassword = password.join("");
if (slug && !isPending && fullPassword.length === 6) {
router.push(`/s/${slug}?password=${encodeURIComponent(fullPassword)}`);
}
});
};
const toggleVisibility = () => {
setIsHidden(!isHidden);
};
return (
<div className="grids grids-dark flex min-h-screen flex-col bg-neutral-100 dark:bg-neutral-900">
<div className="flex flex-1 items-center justify-center">
<div className="mx-3 w-full max-w-md rounded-lg bg-background/60 px-6 py-6 shadow-md backdrop-blur-xl md:px-[50px]">
<h1 className="mb-4 text-center text-2xl font-bold text-neutral-800 dark:text-neutral-50">
Protected Link
</h1>
<div className="mb-4 break-all text-left text-sm text-neutral-600 dark:text-neutral-400">
<p>
You are attempting to access a password-protected link.{" "}
<strong>Please contact the owner to get the password.</strong>{" "}
Learn more about this from our{" "}
<Link
className="underline"
target="_blank"
href="/docs/short-urls#password"
>
docs
</Link>
.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="flex justify-between gap-2">
{password.map((char, index) => (
<Input
key={index}
type={isHidden ? "password" : "text"}
value={char}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
ref={(el) => (inputRefs.current[index] = el as any)}
maxLength={1}
autoFocus={index === 0}
className="h-12 w-12 rounded-md border border-gray-300 text-center text-lg font-medium focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
))}
</div>
{isError && (
<p className="mb-2 animate-fade-in text-left text-sm text-red-500">
Incorrect password. Please try again.
</p>
)}
<div className="flex items-center justify-between">
<Button
type="button"
variant={"ghost"}
onClick={toggleVisibility}
className="flex items-center gap-1 text-neutral-600 transition-colors hover:text-neutral-800 dark:text-neutral-400"
>
{isHidden ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
<span>{isHidden ? "Show" : "Hide"}</span>
</Button>
<Button
type="submit"
variant={"default"}
className="flex items-center gap-2"
disabled={
!(slug && !isPending && password.join("").length === 6)
}
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<Icons.unLock className="size-4" />
)}
{isPending ? "Unlocking..." : "Unlock"}
</Button>
</div>
</form>
</div>
</div>
<footer className="py-4 text-center text-sm text-muted-foreground">
Powered by{" "}
<Link
className="hover:underline"
href={"https://wr.do"}
target="_blank"
style={{ fontFamily: "Bahamas Bold" }}
>
{siteConfig.name}
</Link>
</footer>
</div>
);
export default async function Page() {
return <PasswordPrompt />;
}

View File

@@ -84,6 +84,55 @@ export const Icons = {
calendar: Calendar,
lock: LockKeyhole,
unLock: LockKeyholeOpen,
pwdKey: ({ ...props }: LucideProps) => (
<svg
height="18"
width="18"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g fill="currentColor">
<path
d="M7.75,13.25H3.75c-1.105,0-2-.895-2-2V6.75c0-1.105,.895-2,2-2H14.25c1.105,0,2,.895,2,2v.25"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></path>
<path
d="M12.25,12.25v-2c0-.828,.672-1.5,1.5-1.5h0c.828,0,1.5,.672,1.5,1.5v2"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
></path>
<circle
cx="5.5"
cy="9"
fill="currentColor"
r="1"
stroke="none"
></circle>
<circle cx="9" cy="9" fill="currentColor" r="1" stroke="none"></circle>
<rect
height="4"
width="6"
fill="none"
rx="1"
ry="1"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
x="10.75"
y="12.25"
></rect>
</g>
</svg>
),
fileText: FileText,
dashboard: LayoutPanelLeft,
download: Download,

View File

@@ -0,0 +1,57 @@
import React from "react";
import { cn } from "@/lib/utils";
type SpotlightProps = {
className?: string;
fill?: string;
};
export const Spotlight = ({ className, fill }: SpotlightProps) => {
return (
<svg
className={cn(
"animate-spotlight pointer-events-none absolute z-[1] h-[169%] w-[138%] opacity-0 lg:w-[84%]",
className,
)}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 3787 2842"
fill="none"
>
<g filter="url(#filter)">
<ellipse
cx="1924.71"
cy="273.501"
rx="1924.71"
ry="273.501"
transform="matrix(-0.822377 -0.568943 -0.568943 0.822377 3631.88 2291.09)"
fill={fill || "white"}
fillOpacity="0.21"
></ellipse>
</g>
<defs>
<filter
id="filter"
x="0.860352"
y="0.838989"
width="3785.16"
height="2840.26"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
></feBlend>
<feGaussianBlur
stdDeviation="151"
result="effect1_foregroundBlur_1065_8"
></feGaussianBlur>
</filter>
</defs>
</svg>
);
};

File diff suppressed because one or more lines are too long

View File

@@ -178,6 +178,16 @@ const config = {
transform: "translateX(0px)",
},
},
spotlight: {
"0%": {
opacity: "0",
transform: "translate(-72%, -62%) scale(0.5)",
},
"100%": {
opacity: "1",
transform: "translate(-50%,-40%) scale(1)",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
@@ -196,6 +206,8 @@ const config = {
"fade-in-right": "fade-in-right 0.4s",
"fade-out-left": "fade-out-left 0.4s",
"fade-out-right": "fade-out-right 0.4s",
spotlight: "spotlight 2s ease .75s 1 forwards",
},
},
},