Improves password prompt UI and features
This commit is contained in:
@@ -7,9 +7,10 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- 🔗 **短链生成**:生成附有访问者统计信息的短链接 (支持密码保护)
|
||||
- 📮 **临时邮箱**:创建多个临时邮箱接收和发送邮件
|
||||
- 🌐 **多租户支持**:无缝管理多个 DNS 记录
|
||||
- ⚡ **即时记录创建**:快速设置 CNAME、A 等记录
|
||||
- 🔗 **短链生成**:生成附有访问者统计信息的短链接
|
||||
- 📸 **截图 API**:访问截图 API
|
||||
- <20> **元数据抓取 API**:访问元数据抓取 API
|
||||
- <20>😀 **权限管理**:方便审核的管理员面板
|
||||
|
||||
@@ -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
|
||||
|
||||
174
app/password-prompt/card.tsx
Normal file
174
app/password-prompt/card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
57
components/ui/spotlight.tsx
Normal file
57
components/ui/spotlight.tsx
Normal 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
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user