├── public ├── robots.txt ├── favicon.ico ├── maskable-icon.png ├── apple-touch-icon.png └── fonts │ └── LXGWNeoXiHei.ttf ├── .eslintrc.json ├── picimpact.jpg ├── scripts └── migrate │ ├── requirements.txt │ └── README.md ├── .stackblitzrc ├── lib └── utils │ ├── fetcher.ts │ └── index.ts ├── script.sh ├── types ├── http.ts └── index.ts ├── .npmrc ├── app ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ └── [[...route]] │ │ └── route.ts ├── admin │ ├── upload │ │ └── page.tsx │ ├── page.tsx │ ├── layout.tsx │ ├── list │ │ └── page.tsx │ ├── settings │ │ └── storages │ │ │ └── page.tsx │ ├── copyright │ │ └── page.tsx │ ├── album │ │ └── page.tsx │ └── about │ │ └── page.tsx ├── (default) │ ├── layout.tsx │ ├── page.tsx │ ├── [...album] │ │ └── page.tsx │ └── label │ │ └── [...tag] │ │ └── page.tsx ├── providers │ ├── next-ui-providers.tsx │ ├── toaster-providers.tsx │ ├── progress-bar-providers.tsx │ ├── session-providers.tsx │ └── button-store-Providers.tsx ├── login │ └── page.tsx ├── layout.tsx └── rss.xml │ └── route.ts ├── postcss.config.js ├── netlify.toml ├── .dockerignore ├── server ├── user.ts ├── lib │ ├── db.ts │ ├── r2.ts │ └── s3.ts ├── actions.ts └── auth.ts ├── prisma └── migrations │ ├── migration_lock.toml │ └── 20241121072529_v1 │ └── migration.sql ├── .env.example ├── next-env.d.ts ├── .gitignore ├── hooks ├── useIsHydrated.ts ├── useSWRHydrated.ts ├── useSWRPageTotalHook.ts ├── useSWRPageTotalServerHook.ts ├── useSWRInfiniteServerHook.ts ├── useSWRInfiniteHook.ts └── use-mobile.tsx ├── components ├── ui │ ├── skeleton.tsx │ ├── collapsible.tsx │ ├── label.tsx │ ├── separator.tsx │ ├── progress.tsx │ ├── input.tsx │ ├── checkbox.tsx │ ├── switch.tsx │ ├── tooltip.tsx │ ├── popover.tsx │ ├── avatar.tsx │ ├── alert.tsx │ ├── tabs.tsx │ ├── button.tsx │ ├── card.tsx │ ├── input-otp.tsx │ ├── breadcrumb.tsx │ ├── table.tsx │ ├── dialog.tsx │ ├── sheet.tsx │ ├── form.tsx │ └── alert-dialog.tsx ├── admin │ ├── list │ │ ├── ListImage.tsx │ │ └── ImageHelpSheet.tsx │ ├── SearchButton.tsx │ ├── album │ │ ├── AlbumAddButton.tsx │ │ ├── AlbumHelp.tsx │ │ └── AlbumHelpSheet.tsx │ ├── copyright │ │ └── CopyrightAddButton.tsx │ ├── SearchBorder.tsx │ ├── upload │ │ └── FileUploadHelpSheet.tsx │ ├── settings │ │ └── storages │ │ │ ├── S3Tabs.tsx │ │ │ ├── R2Tabs.tsx │ │ │ ├── AListTabs.tsx │ │ │ ├── S3EditSheet.tsx │ │ │ ├── R2EditSheet.tsx │ │ │ └── AListEditSheet.tsx │ ├── Command.tsx │ └── dashboard │ │ └── CardList.tsx ├── layout │ ├── Logo.tsx │ ├── DynamicNavbar.tsx │ ├── HeaderLink.tsx │ ├── Header.tsx │ ├── admin │ │ ├── nav-title.tsx │ │ ├── nav-projects.tsx │ │ ├── nav-main.tsx │ │ ├── app-sidebar.tsx │ │ └── nav-user.tsx │ └── DropMenu.tsx ├── RefreshButton.tsx ├── LivePhoto.tsx ├── icons │ ├── download.tsx │ ├── circle-help.tsx │ ├── square-pen.tsx │ ├── arrow-right.tsx │ ├── arrow-left.tsx │ ├── link.tsx │ ├── copy.tsx │ └── delete.tsx ├── animata │ ├── container │ │ └── animated-border-trail.tsx │ └── text │ │ └── counter.tsx ├── BlurImage.tsx └── Masonry.tsx ├── components.json ├── .github └── workflows │ ├── greetings.yml │ ├── build-dev.yaml │ ├── build-main.yaml │ └── build-release.yaml ├── hono ├── index.ts ├── storage │ └── alist.ts ├── open │ └── open.ts ├── copyrights.ts ├── albums.ts ├── auth.ts ├── images.ts └── settings.ts ├── next.config.mjs ├── tsconfig.json ├── LICENSE ├── Dockerfile ├── middleware.ts ├── tailwind.config.ts ├── package.json ├── instrumentation.ts └── style └── globals.css /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /picimpact.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/PicImpact/main/picimpact.jpg -------------------------------------------------------------------------------- /scripts/migrate/requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | dataset 3 | sqlalchemy 4 | psycopg2 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/PicImpact/main/public/favicon.ico -------------------------------------------------------------------------------- /.stackblitzrc: -------------------------------------------------------------------------------- 1 | { 2 | "installDependencies": true, 3 | "startCommand": "pnpm run dev" 4 | } 5 | -------------------------------------------------------------------------------- /lib/utils/fetcher.ts: -------------------------------------------------------------------------------- 1 | export const fetcher = (url: string) => fetch(url).then((res) => res.json()); -------------------------------------------------------------------------------- /public/maskable-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/PicImpact/main/public/maskable-icon.png -------------------------------------------------------------------------------- /script.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx prisma migrate deploy 4 | 5 | HOSTNAME="0.0.0.0" node server.js 6 | -------------------------------------------------------------------------------- /types/http.ts: -------------------------------------------------------------------------------- 1 | export type Response = { 2 | code: number, 3 | message: string, 4 | data?: any 5 | } -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/PicImpact/main/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/fonts/LXGWNeoXiHei.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxft/PicImpact/main/public/fonts/LXGWNeoXiHei.ttf -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | shell-emulator=true 4 | auto-install-peers=true 5 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '~/server/auth' 2 | 3 | export const { GET, POST } = handlers; -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build.environment] 2 | NODE_VERSION = "18" 3 | 4 | [build] 5 | publish = "dist" 6 | command = "pnpm run build:netlify" 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | .idea 9 | .github 10 | .gitignore -------------------------------------------------------------------------------- /server/user.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '~/server/auth' 2 | 3 | export async function getCurrentUser() { 4 | const { user } = await auth() 5 | 6 | return user 7 | } -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /app/admin/upload/page.tsx: -------------------------------------------------------------------------------- 1 | import FileUpload from '~/components/admin/upload/FileUpload' 2 | 3 | export default function Upload() { 4 | return ( 5 | 6 | ) 7 | } -------------------------------------------------------------------------------- /prisma/migrations/20241121072529_v1/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "images" ADD COLUMN "type" SMALLINT NOT NULL DEFAULT 1, 3 | ADD COLUMN "video_url" TEXT; 4 | -------------------------------------------------------------------------------- /lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # 数据库 url 2 | DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres" 3 | # AUTH_SECRET npx auth secret 4 | AUTH_SECRET=your-secret-key 5 | # 禁用 Vercel node.js 帮助程序 6 | NODEJS_HELPERS=0 -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist 4 | .output 5 | .idea/ 6 | .vercel/ 7 | 8 | # next.js 9 | /.next/ 10 | /out/ 11 | 12 | # production 13 | /build 14 | 15 | # local env files 16 | .env 17 | 18 | # nextjs --experimental-https cert file 19 | certificates -------------------------------------------------------------------------------- /hooks/useIsHydrated.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export const useIsHydrated = () => { 4 | const [isHydrated, setIsHydrated] = useState(false); 5 | 6 | useEffect(() => { 7 | setIsHydrated(true); 8 | }, []); 9 | 10 | return isHydrated; 11 | }; -------------------------------------------------------------------------------- /app/(default)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from '~/components/layout/Header' 2 | 3 | export default async function DefaultLayout({ 4 | children, 5 | }: Readonly<{ 6 | children: React.ReactNode; 7 | }>) { 8 | return ( 9 | <> 10 |
11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /scripts/migrate/README.md: -------------------------------------------------------------------------------- 1 | # V2 Migration Guide 2 | 3 | 本脚本用于执行从 V1 到 V2 的迁移。 4 | 5 | ## 1. 迁移前准备 6 | 7 | 执行 8 | 9 | ```bash 10 | pip install -r requirements.txt 11 | ``` 12 | 13 | ## 2. 迁移 14 | 15 | 执行 16 | 17 | ```bash 18 | python v1_to_v2_data_migration.py --v1-url --v2-url 19 | ``` 20 | -------------------------------------------------------------------------------- /app/providers/next-ui-providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 4 | 5 | export function ThemeProvider({children}: { children: React.ReactNode }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ) 11 | } -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '~/lib/utils' 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /components/admin/list/ListImage.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react' 4 | 5 | export default function ListImage({ image }: { image: any }) { 6 | return ( 7 | {image.detail} 12 | ) 13 | } -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /server/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | const prismaClientSingleton = () => { 4 | return new PrismaClient() 5 | } 6 | 7 | declare const globalThis: { 8 | prisma: ReturnType; 9 | } & typeof global; 10 | 11 | const prisma = globalThis.prisma ?? prismaClientSingleton() 12 | 13 | export const db = prisma 14 | 15 | globalThis.prisma = prisma -------------------------------------------------------------------------------- /app/providers/toaster-providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Toaster } from 'sonner' 4 | import { useTheme } from 'next-themes' 5 | 6 | export function ToasterProviders() { 7 | const { theme } = useTheme() 8 | 9 | return ( 10 | 16 | ) 17 | } -------------------------------------------------------------------------------- /app/providers/progress-bar-providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { AppProgressBar as ProgressBar } from 'next-nprogress-bar' 4 | 5 | export function ProgressBarProviders({children}: { children: React.ReactNode }) { 6 | return ( 7 | <> 8 | 13 | {children} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /hooks/useSWRHydrated.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import { HandleProps } from '~/types' 3 | 4 | export const useSWRHydrated = ({ handle, args }: HandleProps) => { 5 | const { data, error, isLoading, isValidating, mutate } = useSWR(args, 6 | () => { 7 | return handle() 8 | }, { revalidateOnFocus: false }) 9 | 10 | return { 11 | data, 12 | error, 13 | isLoading: isLoading || isValidating, 14 | mutate 15 | } 16 | } -------------------------------------------------------------------------------- /hooks/useSWRPageTotalHook.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import { ImageHandleProps } from '~/types' 3 | 4 | export const useSWRPageTotalHook = ({ args, totalHandle, album }: ImageHandleProps) => { 5 | const { data, error, isLoading, isValidating, mutate } = useSWR([args, album], 6 | () => { 7 | return totalHandle(album) 8 | }) 9 | 10 | return { 11 | data, 12 | error, 13 | isLoading: isLoading || isValidating, 14 | mutate 15 | } 16 | } -------------------------------------------------------------------------------- /hooks/useSWRPageTotalServerHook.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import { ImageServerHandleProps } from '~/types' 3 | 4 | export const useSWRPageTotalServerHook = ({ args, totalHandle }: ImageServerHandleProps, tag: string) => { 5 | const { data, error, isLoading, isValidating, mutate } = useSWR([args, tag], 6 | () => { 7 | return totalHandle(tag) 8 | }) 9 | 10 | return { 11 | data, 12 | error, 13 | isLoading: isLoading || isValidating, 14 | mutate 15 | } 16 | } -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { UserFrom } from '~/components/login/UserFrom' 2 | import { BackgroundBeamsWithCollision } from '~/components/ui/background-beams-with-collision' 3 | 4 | export default function Login() { 5 | return ( 6 | 7 |
8 |
9 | 10 |
11 |
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "style/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "~/components", 15 | "utils": "~/lib/utils", 16 | "ui": "~/components/ui", 17 | "lib": "~/lib", 18 | "hooks": "~/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /components/admin/SearchButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Search } from 'lucide-react' 4 | import { useButtonStore } from '~/app/providers/button-store-Providers' 5 | import { usePathname } from 'next/navigation' 6 | 7 | export default function SearchButton() { 8 | const pathname = usePathname() 9 | const { setSearchOpen } = useButtonStore( 10 | (state) => state, 11 | ) 12 | 13 | return ( 14 | <> 15 | { 16 | pathname.startsWith('/admin') && setSearchOpen(true)} size={20} /> 17 | } 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /components/admin/album/AlbumAddButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '~/components/ui/button' 4 | import React from 'react' 5 | import { useButtonStore } from '~/app/providers/button-store-Providers' 6 | 7 | export default function AlbumAddButton() { 8 | const { setAlbumAdd } = useButtonStore( 9 | (state) => state, 10 | ) 11 | 12 | return ( 13 | 21 | ) 22 | } -------------------------------------------------------------------------------- /hooks/useSWRInfiniteServerHook.ts: -------------------------------------------------------------------------------- 1 | import useSWR from 'swr' 2 | import { ImageServerHandleProps } from '~/types' 3 | 4 | export const useSWRInfiniteServerHook = ({ handle, args }: ImageServerHandleProps, pageNum: number, tag: string) => { 5 | const { data, error, isLoading, isValidating, mutate } = useSWR([args, pageNum], 6 | () => { 7 | return handle(pageNum, tag) 8 | }, { 9 | revalidateOnFocus: false, 10 | keepPreviousData: true, 11 | }) 12 | 13 | return { 14 | data, 15 | error, 16 | isLoading: isLoading || isValidating, 17 | mutate 18 | } 19 | } -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | issue-message: "非常感谢您提交了 issues,我们很高兴能够与您一起合作解决这个问题。我们将尽快进行审核,并会在 24 小时内回复您。在此期间,如有任何问题,请随时联系我们。再次感谢您的贡献!" 16 | pr-message: "非常感谢您提交了 pr,我们很高兴能够与您一起合并这个 pr。我们将尽快进行审核,并会在 24 小时内回复您。在此期间,如有任何问题,请随时联系我们。再次感谢您的贡献!" 17 | -------------------------------------------------------------------------------- /components/admin/copyright/CopyrightAddButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '~/components/ui/button' 4 | import React from 'react' 5 | import { useButtonStore } from '~/app/providers/button-store-Providers' 6 | 7 | export default function CopyrightAddButton() { 8 | const { setCopyrightAdd } = useButtonStore( 9 | (state) => state, 10 | ) 11 | 12 | return ( 13 | 21 | ) 22 | } -------------------------------------------------------------------------------- /hooks/useSWRInfiniteHook.ts: -------------------------------------------------------------------------------- 1 | import { ImageHandleProps } from '~/types' 2 | import useSWR from 'swr' 3 | 4 | export const useSWRInfiniteHook = ({ handle, args, tag }: ImageHandleProps, pageNum: number) => { 5 | const { data, error, isLoading, isValidating, mutate } = useSWR(`${args}-${pageNum}-${tag}`, 6 | () => { 7 | return handle(pageNum, tag) 8 | }, { 9 | revalidateOnFocus: false, 10 | revalidateIfStale: false, 11 | revalidateOnReconnect: false, 12 | }) 13 | 14 | return { 15 | data, 16 | error, 17 | isLoading: isLoading || isValidating, 18 | mutate, 19 | } 20 | } -------------------------------------------------------------------------------- /components/admin/album/AlbumHelp.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '~/components/ui/button' 4 | import React from 'react' 5 | import { CircleHelpIcon } from '~/components/icons/circle-help' 6 | import { useButtonStore } from '~/app/providers/button-store-Providers' 7 | 8 | export default function AlbumHelp() { 9 | const { setAlbumHelp } = useButtonStore( 10 | (state) => state, 11 | ) 12 | return ( 13 | 21 | ) 22 | } -------------------------------------------------------------------------------- /app/api/[[...route]]/route.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { handle } from 'hono/vercel' 3 | import { Hono } from 'hono' 4 | import route from '~/hono' 5 | import open from '~/hono/open/open' 6 | 7 | const app = new Hono().basePath('/api') 8 | 9 | app.route('/v1', route) 10 | app.route('/open', open) 11 | app.notFound((c) => { 12 | return c.text('not found', 404) 13 | }) 14 | 15 | export const GET = handle(app) 16 | export const POST = handle(app) 17 | export const PUT = handle(app) 18 | export const DELETE = handle(app) 19 | export const dynamic = 'force-dynamic' 20 | export const runtime = 'nodejs' 21 | 22 | export default app 23 | -------------------------------------------------------------------------------- /components/layout/Logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import favicon from '~/public/favicon.svg' 3 | import Link from 'next/link' 4 | import { fetchConfigsByKeys } from '~/server/db/query' 5 | 6 | export default async function Logo() { 7 | const data = await fetchConfigsByKeys([ 8 | 'custom_favicon_url', 9 | ]) 10 | 11 | return ( 12 | 13 | item.config_key === 'custom_favicon_url')?.config_value || favicon} 15 | alt="Logo" 16 | width={36} 17 | height={36} 18 | /> 19 | 20 | ); 21 | } -------------------------------------------------------------------------------- /hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /hono/index.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { Hono } from 'hono' 3 | import settings from '~/hono/settings' 4 | import auth from '~/hono/auth' 5 | import copyrights from '~/hono/copyrights' 6 | import file from '~/hono/file' 7 | import images from '~/hono/images' 8 | import albums from '~/hono/albums' 9 | import alist from '~/hono/storage/alist' 10 | 11 | const route = new Hono() 12 | 13 | route.route('/settings', settings) 14 | route.route('/auth', auth) 15 | route.route('/copyrights', copyrights) 16 | route.route('/file', file) 17 | route.route('/images', images) 18 | route.route('/albums', albums) 19 | route.route('/storage/alist', alist) 20 | 21 | export default route -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "standalone", 4 | reactStrictMode: true, 5 | compiler: { 6 | removeConsole: process.env.NODE_ENV === "production", 7 | }, 8 | serverExternalPackages: ['pg'], 9 | eslint: { 10 | ignoreDuringBuilds: true, 11 | }, 12 | typescript: { 13 | ignoreBuildErrors: true, 14 | }, 15 | images: { 16 | remotePatterns: [ 17 | { 18 | protocol: 'https', 19 | hostname: '**', 20 | }, 21 | { 22 | protocol: 'http', 23 | hostname: '**', 24 | }, 25 | ], 26 | }, 27 | }; 28 | 29 | export default nextConfig; 30 | -------------------------------------------------------------------------------- /app/providers/session-providers.tsx: -------------------------------------------------------------------------------- 1 | import { SessionProvider } from 'next-auth/react' 2 | import { auth } from '~/server/auth' 3 | 4 | export async function SessionProviders({children}: { children: React.ReactNode }) { 5 | const session = await auth() 6 | 7 | if (session?.user) { 8 | // TODO: Look into https://react.dev/reference/react/experimental_taintObjectReference 9 | session.user = { 10 | id: session.user.id, 11 | name: session.user.name, 12 | email: session.user.email, 13 | image: session.user.image, 14 | } 15 | } 16 | 17 | return ( 18 | 19 | {children} 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchImagesAnalysis } from '~/server/db/query' 2 | import CardList from '~/components/admin/dashboard/CardList' 3 | import { DataProps } from '~/types' 4 | 5 | export default async function Admin() { 6 | const getData = async (): Promise<{ 7 | total: number 8 | showTotal: number 9 | crTotal: number 10 | tagsTotal: number 11 | result: any[] 12 | }> => { 13 | 'use server' 14 | // @ts-ignore 15 | return await fetchImagesAnalysis() 16 | } 17 | 18 | const data = await getData() 19 | 20 | const props: DataProps = { 21 | data: data, 22 | } 23 | 24 | return ( 25 |
26 | 27 |
28 | ) 29 | } -------------------------------------------------------------------------------- /components/layout/DynamicNavbar.tsx: -------------------------------------------------------------------------------- 1 | import VaulDrawer from '~/components/layout/VaulDrawer' 2 | import { DropMenu } from '~/components/layout/DropMenu' 3 | import { fetchAlbumsShow } from '~/server/db/query' 4 | import { DataProps } from '~/types' 5 | 6 | export default async function DynamicNavbar() { 7 | const getData = async () => { 8 | 'use server' 9 | return await fetchAlbumsShow() 10 | } 11 | 12 | const data = await getData() 13 | 14 | const props: DataProps = { 15 | data: data 16 | } 17 | 18 | return ( 19 | <> 20 |
21 | 22 |
23 |
24 | 25 |
26 | 27 | ) 28 | } -------------------------------------------------------------------------------- /components/admin/SearchBorder.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Button } from '~/components/ui/button' 4 | import { cn } from '~/lib/utils' 5 | import { MagnifyingGlassIcon } from '@radix-ui/react-icons' 6 | import { useButtonStore } from '~/app/providers/button-store-Providers' 7 | 8 | export default function SearchBorder() { 9 | const { setSearchOpen } = useButtonStore( 10 | (state) => state, 11 | ) 12 | 13 | return ( 14 | 25 | ) 26 | } -------------------------------------------------------------------------------- /components/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react' 4 | import { useSWRHydrated } from '~/hooks/useSWRHydrated' 5 | import { HandleProps } from '~/types' 6 | import { ReloadIcon } from '@radix-ui/react-icons' 7 | import { Button } from '~/components/ui/button' 8 | 9 | export default function RefreshButton(props: Readonly) { 10 | const { isLoading, mutate, error } = useSWRHydrated(props) 11 | 12 | return ( 13 | 25 | ) 26 | } -------------------------------------------------------------------------------- /app/(default)/page.tsx: -------------------------------------------------------------------------------- 1 | import Masonry from '~/components/Masonry' 2 | import { fetchClientImagesListByAlbum, fetchClientImagesPageTotalByAlbum } from '~/server/db/query' 3 | import { ImageHandleProps } from '~/types' 4 | 5 | export default async function Home() { 6 | const getData = async (pageNum: number, album: string) => { 7 | 'use server' 8 | return await fetchClientImagesListByAlbum(pageNum, album) 9 | } 10 | 11 | const getPageTotal = async (album: string) => { 12 | 'use server' 13 | return await fetchClientImagesPageTotalByAlbum(album) 14 | } 15 | 16 | const props: ImageHandleProps = { 17 | handle: getData, 18 | args: 'getImages-client', 19 | album: '/', 20 | totalHandle: getPageTotal 21 | } 22 | 23 | return ( 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/admin/layout.tsx: -------------------------------------------------------------------------------- 1 | import { AntdRegistry } from '@ant-design/nextjs-registry' 2 | import Command from '~/components/admin/Command' 3 | import { AppSidebar } from '~/components/layout/admin/app-sidebar' 4 | import { SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar" 5 | 6 | export default function AdminLayout({ 7 | children, 8 | }: Readonly<{ 9 | children: React.ReactNode; 10 | }>) { 11 | return ( 12 | 13 | 14 |
15 | 16 | 17 | 18 |
19 | {children} 20 |
21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/admin/list/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | fetchServerImagesListByAlbum, 3 | fetchServerImagesPageTotalByAlbum 4 | } from '~/server/db/query' 5 | import { ImageServerHandleProps } from '~/types' 6 | import ListProps from '~/components/admin/list/ListProps' 7 | 8 | export default async function List() { 9 | const getData = async (pageNum: number, Album: string) => { 10 | 'use server' 11 | return await fetchServerImagesListByAlbum(pageNum, Album) 12 | } 13 | 14 | const getTotal = async (Album: string) => { 15 | 'use server' 16 | return await fetchServerImagesPageTotalByAlbum(Album) 17 | } 18 | 19 | const props: ImageServerHandleProps = { 20 | handle: getData, 21 | args: 'getImages-server', 22 | totalHandle: getTotal, 23 | } 24 | 25 | return ( 26 | 27 | ) 28 | } -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "~/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /app/(default)/[...album]/page.tsx: -------------------------------------------------------------------------------- 1 | import Masonry from '~/components/Masonry' 2 | import { fetchClientImagesListByAlbum, fetchClientImagesPageTotalByAlbum } from '~/server/db/query' 3 | import { ImageHandleProps } from '~/types' 4 | 5 | export default async function Page({params}: { params: any }) { 6 | const { album } = await params 7 | const getData = async (pageNum: number, Album: string) => { 8 | 'use server' 9 | return await fetchClientImagesListByAlbum(pageNum, Album) 10 | } 11 | 12 | const getPageTotal = async (Album: string) => { 13 | 'use server' 14 | return await fetchClientImagesPageTotalByAlbum(Album) 15 | } 16 | 17 | const props: ImageHandleProps = { 18 | handle: getData, 19 | args: 'getImages-client', 20 | album: `/${album}`, 21 | totalHandle: getPageTotal 22 | } 23 | 24 | return ( 25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /app/(default)/label/[...tag]/page.tsx: -------------------------------------------------------------------------------- 1 | import Masonry from '~/components/Masonry' 2 | import { fetchClientImagesListByTag, fetchClientImagesPageTotalByTag } from '~/server/db/query' 3 | import { ImageHandleProps } from '~/types' 4 | 5 | export default async function Label({params}: { params: any }) { 6 | const { tag } = await params 7 | const getData = async (pageNum: number, tag: string) => { 8 | 'use server' 9 | return await fetchClientImagesListByTag(pageNum, tag) 10 | } 11 | 12 | const getPageTotal = async (tag: string) => { 13 | 'use server' 14 | return await fetchClientImagesPageTotalByTag(tag) 15 | } 16 | 17 | const props: ImageHandleProps = { 18 | handle: getData, 19 | args: `getImages-client-label`, 20 | album: `${decodeURIComponent(tag)}`, 21 | totalHandle: getPageTotal 22 | } 23 | 24 | return ( 25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as SeparatorPrimitive from '@radix-ui/react-separator' 5 | 6 | import { cn } from '~/lib/utils' 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as ProgressPrimitive from '@radix-ui/react-progress' 5 | 6 | import { cn } from '~/lib/utils' 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /components/layout/HeaderLink.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { AlbumType } from '~/types' 4 | import { usePathname } from 'next/navigation' 5 | import { useRouter } from 'next-nprogress-bar' 6 | import { DataProps } from '~/types' 7 | import { Button } from '~/components/ui/button' 8 | 9 | export default function HeaderLink(props: Readonly) { 10 | const pathname = usePathname() 11 | const router = useRouter() 12 | return ( 13 | <> 14 | {Array.isArray(props.data) && props.data?.map((album: AlbumType) => ( 15 | 24 | ))} 25 | 26 | ) 27 | } -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '~/lib/utils' 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /server/lib/r2.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from '@aws-sdk/client-s3' 2 | 3 | let s3R2Client: S3Client | null = null; 4 | 5 | export function getR2Client(findConfig: any[]) { 6 | if (!findConfig.length) { 7 | console.warn('警告:无法获取 R2 配置信息,请配置相应信息。'); 8 | } 9 | if (s3R2Client) return s3R2Client 10 | 11 | const r2AccesskeyId = findConfig.find((item: any) => item.config_key === 'r2_accesskey_id')?.config_value || ''; 12 | const r2AccesskeySecret = findConfig.find((item: any) => item.config_key === 'r2_accesskey_secret')?.config_value || ''; 13 | const r2Endpoint = findConfig.find((item: any) => item.config_key === 'r2_endpoint')?.config_value || ''; 14 | 15 | s3R2Client = new S3Client({ 16 | region: "auto", 17 | endpoint: r2Endpoint.includes('https://') ? r2Endpoint : `https://${r2Endpoint}`, 18 | credentials: { 19 | accessKeyId: r2AccesskeyId, 20 | secretAccessKey: r2AccesskeySecret, 21 | }, 22 | }); 23 | 24 | return s3R2Client; 25 | } -------------------------------------------------------------------------------- /hono/storage/alist.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { fetchConfigsByKeys } from '~/server/db/query' 3 | 4 | import { Hono } from 'hono' 5 | 6 | const app = new Hono() 7 | 8 | app.get('/info', async (c) => { 9 | const data = await fetchConfigsByKeys([ 10 | 'alist_url', 11 | 'alist_token' 12 | ]); 13 | return c.json(data) 14 | }) 15 | 16 | app.get('/storages', async (c) => { 17 | const findConfig = await fetchConfigsByKeys([ 18 | 'alist_url', 19 | 'alist_token' 20 | ]) 21 | const alistToken = findConfig.find((item: any) => item.config_key === 'alist_token')?.config_value || ''; 22 | const alistUrl = findConfig.find((item: any) => item.config_key === 'alist_url')?.config_value || ''; 23 | 24 | const data = await fetch(`${alistUrl}/api/admin/storage/list`, { 25 | method: 'get', 26 | headers: { 27 | 'Authorization': alistToken.toString(), 28 | }, 29 | }).then(res => res.json()) 30 | return c.json(data) 31 | }) 32 | 33 | export default app -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 5 | "useDefineForClassFields": true, 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "ESNext", 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "plugins": [{ "name": "next" }], 23 | "paths": { 24 | "~/*": [ 25 | "./*" 26 | ] 27 | }, 28 | }, 29 | "include": [ 30 | "next-env.d.ts", 31 | "**/*.ts", 32 | "**/*.tsx", 33 | ".next/types/**/*.ts" 34 | ], 35 | "exclude": [ 36 | "node_modules" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /components/LivePhoto.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { useEffect, useRef } from 'react' 5 | import { cn } from '~/lib/utils' 6 | 7 | export default function LivePhoto({ url, videoUrl, className }: { url: string; videoUrl: string; className?: string }) { 8 | const livePhotoRef = useRef(null) 9 | 10 | useEffect(() => { 11 | const initializeLivePhotosKit = async () => { 12 | const LivePhotosKit = (await import('livephotoskit')) 13 | if (livePhotoRef.current && url && videoUrl) { 14 | LivePhotosKit.augmentElementAsPlayer(livePhotoRef.current, { 15 | effectType: 'live', 16 | photoSrc: url, 17 | videoSrc: videoUrl, 18 | showsNativeControls: true, 19 | }) 20 | } 21 | }; 22 | 23 | initializeLivePhotosKit(); 24 | }, [livePhotoRef, url, videoUrl]) 25 | 26 | return ( 27 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '~/components/layout/Logo' 2 | import DynamicNavbar from '~/components/layout/DynamicNavbar' 3 | import HeaderLink from '~/components/layout/HeaderLink' 4 | import { fetchAlbumsShow } from '~/server/db/query' 5 | import { DataProps } from '~/types' 6 | 7 | export default async function Header() { 8 | const getData = async () => { 9 | 'use server' 10 | return await fetchAlbumsShow() 11 | } 12 | 13 | const data = await getData() 14 | 15 | const props: DataProps = { 16 | data: data 17 | } 18 | 19 | return ( 20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 |
29 | 30 |
31 |
32 | ); 33 | } -------------------------------------------------------------------------------- /app/admin/settings/storages/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import AListTabs from '~/components/admin/settings/storages/AListTabs' 4 | import S3Tabs from '~/components/admin/settings/storages/S3Tabs' 5 | import R2Tabs from '~/components/admin/settings/storages/R2Tabs' 6 | import { 7 | Tabs, 8 | TabsContent, 9 | TabsList, 10 | TabsTrigger, 11 | } from '~/components/ui/tabs' 12 | 13 | export default function Storages() { 14 | 15 | return ( 16 |
17 | 18 | 19 | S3 API 20 | Cloudflare R2 21 | AList API 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 | ) 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-PRESENT Bess Croft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox' 5 | import { CheckIcon } from '@radix-ui/react-icons' 6 | 7 | import { cn } from '~/lib/utils' 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /.github/workflows/build-dev.yaml: -------------------------------------------------------------------------------- 1 | name: Dev Docker Multi-arch Image CI & CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - v2 7 | 8 | jobs: 9 | build: 10 | name: Running Compile Next Multi-arch Docker Image 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout PicImpact 14 | uses: actions/checkout@v4 15 | - name: Get Version 16 | id: get_version 17 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 18 | - name: Login to Docker Hub 19 | uses: docker/login-action@v3 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | - name: Set up Docker Buildx 26 | id: set_up_buildx 27 | uses: docker/setup-buildx-action@v3 28 | - name: Build and push dev 29 | id: docker_build 30 | uses: docker/build-push-action@v5 31 | with: 32 | context: ./ 33 | file: ./Dockerfile 34 | platforms: linux/amd64,linux/arm64 35 | push: true 36 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/picimpact:dev -------------------------------------------------------------------------------- /.github/workflows/build-main.yaml: -------------------------------------------------------------------------------- 1 | name: Dev Docker Multi-arch Image CI & CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | name: Running Compile Next Multi-arch Docker Image 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout PicImpact 14 | uses: actions/checkout@v4 15 | - name: Get Version 16 | id: get_version 17 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 18 | - name: Login to Docker Hub 19 | uses: docker/login-action@v3 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | - name: Set up Docker Buildx 26 | id: set_up_buildx 27 | uses: docker/setup-buildx-action@v3 28 | - name: Build and push main 29 | id: docker_build 30 | uses: docker/build-push-action@v5 31 | with: 32 | context: ./ 33 | file: ./Dockerfile 34 | platforms: linux/amd64,linux/arm64 35 | push: true 36 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/picimpact:latest 37 | -------------------------------------------------------------------------------- /components/admin/album/AlbumHelpSheet.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '~/components/ui/sheet' 4 | import { useButtonStore } from '~/app/providers/button-store-Providers' 5 | 6 | export default function AlbumHelpSheet() { 7 | const { albumHelp, setAlbumHelp } = useButtonStore( 8 | (state) => state, 9 | ) 10 | 11 | return ( 12 | { 16 | if (!open) { 17 | setAlbumHelp(false) 18 | } 19 | }} 20 | modal={false} 21 | > 22 | 23 | 24 | 帮助 25 | 26 |

27 | 您要展示除⌈首页⌋外的其它相册,需要添加新的⌈相册⌋,并标记为可显示状态。 28 |

29 |

30 | ⌈相册⌋的⌈路由⌋需要带 / 前缀。 31 |

32 |

33 | 注意,如果您将⌈相册⌋设置为⌈禁用状态⌋,那么未登录时无法以任何方式获取该⌈相册⌋下的图片信息。 34 |

35 |
36 |
37 |
38 |
39 | ) 40 | } -------------------------------------------------------------------------------- /.github/workflows/build-release.yaml: -------------------------------------------------------------------------------- 1 | name: Release Docker Multi-arch Image CI & CD 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | name: Running Compile Next Multi-arch Docker Image 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout PicImpact 14 | uses: actions/checkout@v4 15 | - name: Get Version 16 | id: get_version 17 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 18 | - name: Login to Docker Hub 19 | uses: docker/login-action@v3 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | - name: Set up Docker Buildx 26 | id: set_up_buildx 27 | uses: docker/setup-buildx-action@v3 28 | - name: Build and push version 29 | id: docker_build_version 30 | uses: docker/build-push-action@v5 31 | with: 32 | context: ./ 33 | file: ./Dockerfile 34 | platforms: linux/amd64,linux/arm64 35 | push: true 36 | tags: ${{ secrets.DOCKERHUB_USERNAME }}/picimpact:${{ steps.get_version.outputs.VERSION }} 37 | -------------------------------------------------------------------------------- /app/providers/button-store-Providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { type ReactNode, createContext, useRef, useContext } from 'react' 4 | import { type StoreApi, useStore } from 'zustand' 5 | 6 | import { type ButtonStore, createButtonStore, initButtonStore } from '~/stores/buttonStores' 7 | 8 | export const ButtonStoreContext = createContext | null>( 9 | null, 10 | ) 11 | 12 | export interface ButtonStoreProviderProps { 13 | children: ReactNode 14 | } 15 | 16 | export const ButtonStoreProvider = ({ 17 | children, 18 | }: ButtonStoreProviderProps) => { 19 | const storeRef = useRef>() 20 | if (!storeRef.current) { 21 | storeRef.current = createButtonStore(initButtonStore()) 22 | } 23 | 24 | return ( 25 | 26 | {children} 27 | 28 | ) 29 | } 30 | 31 | export const useButtonStore = ( 32 | selector: (store: ButtonStore) => T, 33 | ): T => { 34 | const buttonStoreContext = useContext(ButtonStoreContext) 35 | 36 | if (!buttonStoreContext) { 37 | throw new Error(`useButtonStore must be use within ButtonStoreProvider`) 38 | } 39 | 40 | return useStore(buttonStoreContext, selector) 41 | } 42 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as SwitchPrimitives from '@radix-ui/react-switch' 5 | 6 | import { cn } from '~/lib/utils' 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /hono/open/open.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { Hono } from 'hono' 3 | import { fetchImageByIdAndAuth, queryAuthStatus } from '~/server/db/query' 4 | 5 | const app = new Hono() 6 | 7 | app.get('/get-auth-status', async (c) => { 8 | try { 9 | const data = await queryAuthStatus(); 10 | 11 | return c.json({ 12 | code: 200, 13 | message: '获取双因素状态成功!', 14 | data: { 15 | auth_enable: data?.config_value 16 | } 17 | }) 18 | } catch (e) { 19 | console.log(e) 20 | return c.json({ code: 500, message: '获取双因素状态失败!' }) 21 | } 22 | }) 23 | 24 | app.get('/get-image-blob', async (c) => { 25 | const { searchParams } = new URL(c.req.url) 26 | const imageUrl = searchParams.get('imageUrl') 27 | // @ts-ignore 28 | const blob = await fetch(imageUrl).then(res => res.blob()) 29 | return new Response(blob) 30 | }) 31 | 32 | app.get('/get-image-by-id', async (c) => { 33 | const { searchParams } = new URL(c.req.url) 34 | const id = searchParams.get('id') 35 | const data = await fetchImageByIdAndAuth(String(id)); 36 | if (data && data?.length > 0) { 37 | return c.json({ code: 200, message: '图片数据获取成功!', data: data }) 38 | } else { 39 | return c.json({ code: 500, message: '图片不存在或未公开展示!' }) 40 | } 41 | }) 42 | 43 | export default app 44 | -------------------------------------------------------------------------------- /components/layout/admin/nav-title.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | DropdownMenu, 6 | DropdownMenuTrigger, 7 | } from '~/components/ui/dropdown-menu' 8 | import { 9 | SidebarMenu, 10 | SidebarMenuButton, 11 | SidebarMenuItem, 12 | } from '~/components/ui/sidebar' 13 | import Image from 'next/image' 14 | import favicon from '~/public/favicon.svg' 15 | import { useRouter } from 'next-nprogress-bar' 16 | 17 | export function NavTitle() { 18 | const router = useRouter() 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 | router.push('/')}> 26 |
27 | Logo 28 |
29 |
30 | 31 | {'PicImpact'} 32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 5 | 6 | import { cn } from '~/lib/utils' 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 19 | 28 | 29 | )) 30 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 31 | 32 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 33 | -------------------------------------------------------------------------------- /components/admin/list/ImageHelpSheet.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '~/components/ui/sheet' 4 | import { useButtonStore } from '~/app/providers/button-store-Providers' 5 | 6 | export default function ImageHelpSheet() { 7 | const { imageHelp, setImageHelp } = useButtonStore( 8 | (state) => state, 9 | ) 10 | 11 | return ( 12 | { 16 | if (!open) { 17 | setImageHelp(false) 18 | } 19 | }} 20 | modal={false} 21 | > 22 | 23 | 24 | 帮助 25 | 26 |

27 | 您在当前页面可以维护图片的数据。 28 |

29 |

30 | 您可以为每一张图片打上标签,但请注意不要用特殊字符。 31 | 为了兼容 SSR 场景,通过路由来获取的参数,如果有特殊字符可能会没法正确访问数据。 32 | 您可以通过点击图片标签,来访问所有包含该标签的图片。 33 |

34 |

35 | 注意,如果您将⌈图片⌋设置为⌈禁用状态⌋,那么未登录时无法以任何方式获取该图片的信息,但不影响通过链接访问图片,因为这个权限由存储方管理。 36 |

37 |
38 |
39 |
40 |
41 | ) 42 | } -------------------------------------------------------------------------------- /components/icons/download.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Variants } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const arrowVariants: Variants = { 7 | normal: { y: 0 }, 8 | animate: { 9 | y: 2, 10 | transition: { 11 | type: 'spring', 12 | stiffness: 200, 13 | damping: 10, 14 | mass: 1, 15 | }, 16 | }, 17 | }; 18 | 19 | const DownloadIcon = () => { 20 | const controls = useAnimation(); 21 | 22 | return ( 23 |
controls.start('animate')} 26 | onMouseLeave={() => controls.start('normal')} 27 | > 28 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
46 | ); 47 | }; 48 | 49 | export { DownloadIcon }; 50 | -------------------------------------------------------------------------------- /components/icons/circle-help.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Variants } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const variants: Variants = { 7 | normal: { rotate: 0 }, 8 | animate: { rotate: [0, -10, 10, -10, 0] }, 9 | }; 10 | 11 | const CircleHelpIcon = () => { 12 | const controls = useAnimation(); 13 | 14 | return ( 15 |
controls.start('animate')} 18 | onMouseLeave={() => controls.start('normal')} 19 | > 20 | 31 | 32 | 40 | 41 | 42 | 43 | 44 |
45 | ); 46 | }; 47 | 48 | export { CircleHelpIcon }; 49 | -------------------------------------------------------------------------------- /components/layout/admin/nav-projects.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { type LucideIcon } from 'lucide-react' 4 | import { 5 | SidebarGroup, 6 | SidebarGroupLabel, 7 | SidebarMenu, 8 | SidebarMenuButton, 9 | SidebarMenuItem, 10 | } from '~/components/ui/sidebar' 11 | import { usePathname } from 'next/navigation' 12 | import { useRouter } from 'next-nprogress-bar' 13 | 14 | export function NavProjects({ 15 | projects, 16 | }: { 17 | projects: { 18 | title: string 19 | items?: { 20 | name: string 21 | url: string 22 | icon: LucideIcon 23 | }[] 24 | } 25 | }) { 26 | const router = useRouter() 27 | const pathname = usePathname() 28 | const buttonClasses = 'active:scale-95 duration-200 ease-in-out' 29 | 30 | return ( 31 | 32 | {projects.title} 33 | 34 | {projects?.items.map((item) => ( 35 | 36 | router.push(item.url)}> 37 | 38 | 39 | {item.name} 40 | 41 | 42 | 43 | ))} 44 | 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as PopoverPrimitive from '@radix-ui/react-popover' 5 | 6 | import { cn } from "~/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverAnchor = PopoverPrimitive.Anchor 13 | 14 | const PopoverContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 18 | 19 | 29 | 30 | )) 31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 32 | 33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 34 | -------------------------------------------------------------------------------- /components/animata/container/animated-border-trail.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '~/lib/utils' 2 | 3 | interface AnimatedTrailProps extends React.HTMLAttributes { 4 | /** 5 | * The duration of the animation. 6 | * @default "10s" 7 | */ 8 | duration?: string; 9 | 10 | contentClassName?: string; 11 | 12 | trailColor?: string; 13 | trailSize?: "sm" | "md" | "lg"; 14 | } 15 | 16 | const sizes = { 17 | sm: 5, 18 | md: 10, 19 | lg: 20, 20 | }; 21 | 22 | export default function AnimatedBorderTrail({ 23 | children, 24 | className, 25 | duration = "10s", 26 | trailColor = "purple", 27 | trailSize = "md", 28 | contentClassName, 29 | ...props 30 | }: AnimatedTrailProps) { 31 | return ( 32 |
36 |
44 |
50 | {children} 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/admin/copyright/page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchCopyrightList } from '~/server/db/query' 2 | import { HandleProps } from '~/types' 3 | import React from 'react' 4 | import CopyrightList from '~/components/admin/copyright/CopyrightList' 5 | import CopyrightAddButton from '~/components/admin/copyright/CopyrightAddButton' 6 | import RefreshButton from '~/components/RefreshButton' 7 | import CopyrightAddSheet from '~/components/admin/copyright/CopyrightAddSheet' 8 | import CopyrightEditSheet from '~/components/admin/copyright/CopyrightEditSheet' 9 | 10 | export default async function Copyright() { 11 | const getData = async () => { 12 | 'use server' 13 | return await fetchCopyrightList() 14 | } 15 | 16 | const props: HandleProps = { 17 | handle: getData, 18 | args: 'getCopyright', 19 | } 20 | 21 | return ( 22 |
23 |
24 |
25 |
26 |

版权管理

27 |
28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 | 37 |
38 | ) 39 | } -------------------------------------------------------------------------------- /server/lib/s3.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from '@aws-sdk/client-s3' 2 | 3 | let s3Client: S3Client | null = null; 4 | 5 | export function getClient(findConfig: any[]) { 6 | if (!findConfig.length) { 7 | console.warn('警告:无法获取 S3 配置信息,请配置相应信息。'); 8 | } 9 | if (s3Client) return s3Client 10 | 11 | const accesskeyId = findConfig.find((item: any) => item.config_key === 'accesskey_id')?.config_value || ''; 12 | const accesskeySecret = findConfig.find((item: any) => item.config_key === 'accesskey_secret')?.config_value || ''; 13 | const region = findConfig.find((item: any) => item.config_key === 'region')?.config_value || ''; 14 | const endpoint = findConfig.find((item: any) => item.config_key === 'endpoint')?.config_value || ''; 15 | const forcePathStyle = findConfig.find((item: any) => item.config_key === 'force_path_style')?.config_value; 16 | 17 | if (forcePathStyle && forcePathStyle === 'true') { 18 | s3Client = new S3Client({ 19 | region: region, 20 | endpoint: endpoint.includes('https://') ? endpoint : `https://${endpoint}`, 21 | credentials: { 22 | accessKeyId: accesskeyId, 23 | secretAccessKey: accesskeySecret, 24 | }, 25 | forcePathStyle: true, 26 | }); 27 | } else { 28 | s3Client = new S3Client({ 29 | region: region, 30 | endpoint: endpoint.includes('https://') ? endpoint : `https://${endpoint}`, 31 | credentials: { 32 | accessKeyId: accesskeyId, 33 | secretAccessKey: accesskeySecret, 34 | }, 35 | }); 36 | } 37 | 38 | return s3Client; 39 | } -------------------------------------------------------------------------------- /hono/copyrights.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { 3 | deleteCopyright, 4 | insertCopyright, 5 | updateCopyright, 6 | updateCopyrightShow 7 | } from '~/server/db/operate' 8 | import { fetchCopyrightList } from '~/server/db/query' 9 | import { Hono } from 'hono' 10 | 11 | const app = new Hono() 12 | 13 | app.get('/get', async (c) => { 14 | const data = await fetchCopyrightList() 15 | if (Array.isArray(data)) { 16 | const result = data.map((item) => ({ label: item.name, value: item.id })) 17 | return c.json(result) 18 | } 19 | return c.json([]) 20 | }) 21 | 22 | app.post('/add', async (c) => { 23 | const copyright = await c.req.json() 24 | try { 25 | await insertCopyright(copyright); 26 | return c.json({ code: 200, message: '新增成功!' }) 27 | } catch (e) { 28 | console.log(e) 29 | return c.json({ code: 500, message: '新增失败!' }) 30 | } 31 | }) 32 | 33 | app.delete('/delete/:id', async (c) => { 34 | const { id } = c.req.param() 35 | const data = await deleteCopyright(id); 36 | return c.json(data) 37 | }) 38 | 39 | app.put('/update', async (c) => { 40 | const copyright = await c.req.json() 41 | try { 42 | await updateCopyright(copyright); 43 | return c.json({ code: 200, message: '更新成功!' }) 44 | } catch (e) { 45 | console.log(e) 46 | return c.json({ code: 500, message: '更新失败!' }) 47 | } 48 | }) 49 | 50 | app.put('/update-show', async (c) => { 51 | const copyright = await c.req.json() 52 | const data = await updateCopyrightShow(copyright.id, copyright.show); 53 | return c.json(data) 54 | }) 55 | 56 | export default app 57 | -------------------------------------------------------------------------------- /components/icons/square-pen.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Variants } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const penVariants: Variants = { 7 | normal: { 8 | rotate: 0, 9 | x: 0, 10 | y: 0, 11 | }, 12 | animate: { 13 | rotate: [-0.5, 0.5, -0.5], 14 | x: [0, -1, 1.5, 0], 15 | y: [0, 1.5, -1, 0], 16 | }, 17 | }; 18 | 19 | const SquarePenIcon = () => { 20 | const controls = useAnimation(); 21 | 22 | return ( 23 |
controls.start('animate')} 26 | onMouseLeave={() => controls.start('normal')} 27 | > 28 | 39 | 40 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | export { SquarePenIcon }; 56 | -------------------------------------------------------------------------------- /app/admin/album/page.tsx: -------------------------------------------------------------------------------- 1 | import { fetchAlbumsList } from '~/server/db/query' 2 | import AlbumList from '~/components/admin/album/AlbumList' 3 | import RefreshButton from '~/components/RefreshButton' 4 | import { HandleProps } from '~/types' 5 | import React from 'react' 6 | import AlbumAddSheet from '~/components/admin/album/AlbumAddSheet' 7 | import AlbumAddButton from '~/components/admin/album/AlbumAddButton' 8 | import AlbumEditSheet from '~/components/admin/album/AlbumEditSheet' 9 | import AlbumHelpSheet from '~/components/admin/album/AlbumHelpSheet' 10 | import AlbumHelp from '~/components/admin/album/AlbumHelp' 11 | 12 | export default async function List() { 13 | 14 | const getData = async () => { 15 | 'use server' 16 | return await fetchAlbumsList() 17 | } 18 | 19 | const props: HandleProps = { 20 | handle: getData, 21 | args: 'getAlbums', 22 | } 23 | 24 | return ( 25 |
26 |
27 |
28 |
29 |

相册管理

30 |
31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 |
43 | ) 44 | } -------------------------------------------------------------------------------- /components/icons/arrow-right.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Variants } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const pathVariants: Variants = { 7 | normal: { d: 'M5 12h14' }, 8 | animate: { 9 | d: ['M5 12h14', 'M5 12h9', 'M5 12h14'], 10 | transition: { 11 | duration: 0.4, 12 | }, 13 | }, 14 | }; 15 | 16 | const secondaryPathVariants: Variants = { 17 | normal: { d: 'm12 5 7 7-7 7', translateX: 0 }, 18 | animate: { 19 | d: 'm12 5 7 7-7 7', 20 | translateX: [0, -3, 0], 21 | transition: { 22 | duration: 0.4, 23 | }, 24 | }, 25 | }; 26 | 27 | const ArrowRightIcon = () => { 28 | const controls = useAnimation(); 29 | 30 | return ( 31 |
controls.start('animate')} 34 | onMouseLeave={() => controls.start('normal')} 35 | > 36 | 47 | 48 | 53 | 54 |
55 | ); 56 | }; 57 | 58 | export { ArrowRightIcon }; 59 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 5 | 6 | import { cn } from '~/lib/utils' 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /hono/albums.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { fetchAlbumsList } from '~/server/db/query' 3 | import { deleteAlbum, insertAlbums, updateAlbum, updateAlbumShow } from '~/server/db/operate' 4 | import { Hono } from 'hono' 5 | 6 | const app = new Hono() 7 | 8 | app.get('/get', async (c) => { 9 | const data = await fetchAlbumsList(); 10 | return c.json(data) 11 | }) 12 | 13 | app.post('/add', async (c) => { 14 | const album = await c.req.json() 15 | if (album.album_value && album.album_value.charAt(0) !== '/') { 16 | return c.json({ 17 | code: 500, 18 | message: '路由必须以 / 开头!' 19 | }) 20 | } 21 | try { 22 | await insertAlbums(album); 23 | return c.json({ code: 200, message: '新增成功!' }) 24 | } catch (e) { 25 | console.log(e) 26 | return c.json({ code: 500, message: '新增失败!' }) 27 | } 28 | }) 29 | 30 | app.put('/update', async (c) => { 31 | const album = await c.req.json() 32 | if (album.album_value && album.album_value.charAt(0) !== '/') { 33 | return c.json({ 34 | code: 500, 35 | message: '路由必须以 / 开头!' 36 | }) 37 | } 38 | try { 39 | await updateAlbum(album); 40 | return c.json({ code: 200, message: '更新成功!' }) 41 | } catch (e) { 42 | console.log(e) 43 | return c.json({ code: 500, message: '更新失败!' }) 44 | } 45 | }) 46 | 47 | app.delete('/delete/:id', async (c) => { 48 | const { id } = c.req.param() 49 | const data = await deleteAlbum(id); 50 | return c.json(data) 51 | }) 52 | 53 | app.put('/update-show', async (c) => { 54 | const album = await c.req.json() 55 | const data = await updateAlbumShow(album.id, album.show); 56 | return c.json(data) 57 | }) 58 | 59 | export default app -------------------------------------------------------------------------------- /server/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { signIn, signOut } from '~/server/auth' 4 | import { queryAuthSecret, queryAuthStatus } from '~/server/db/query' 5 | import * as OTPAuth from 'otpauth' 6 | 7 | export async function authenticate( 8 | email: string, password: string, token: string 9 | ) { 10 | try { 11 | const enable = await queryAuthStatus(); 12 | if (enable?.config_value === 'true') { 13 | const secret = await queryAuthSecret(); 14 | let totp = new OTPAuth.TOTP({ 15 | issuer: "PicImpact", 16 | label: "admin", 17 | algorithm: "SHA512", 18 | digits: 6, 19 | period: 30, 20 | // @ts-ignore 21 | secret: OTPAuth.Secret.fromBase32(secret?.config_value), 22 | }); 23 | let delta = totp.validate({ token: token, window: 1 }) 24 | if (delta === 0) { 25 | try { 26 | await signIn('Credentials', { 27 | email: email, 28 | password: password, 29 | redirect: false, 30 | }); 31 | } catch (e) { 32 | throw new Error('登录失败!') 33 | } 34 | } else { 35 | throw new Error('双因素口令验证失败!') 36 | } 37 | } else { 38 | try { 39 | await signIn('Credentials', { 40 | email: email, 41 | password: password, 42 | redirect: false, 43 | }); 44 | } catch (e) { 45 | throw new Error('登录失败!') 46 | } 47 | } 48 | } catch (error) { 49 | throw error 50 | } 51 | } 52 | 53 | export async function loginOut() { 54 | try { 55 | await signOut({ 56 | redirect: false, 57 | }); 58 | } catch (error) { 59 | throw error; 60 | } 61 | } -------------------------------------------------------------------------------- /components/icons/arrow-left.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Variants } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const pathVariants: Variants = { 7 | normal: { d: 'm12 19-7-7 7-7', translateX: 0 }, 8 | animate: { 9 | d: 'm12 19-7-7 7-7', 10 | translateX: [0, 3, 0], 11 | transition: { 12 | duration: 0.4, 13 | }, 14 | }, 15 | }; 16 | 17 | const secondPathVariants: Variants = { 18 | normal: { d: 'M19 12H5' }, 19 | animate: { 20 | d: ['M19 12H5', 'M19 12H10', 'M19 12H5'], 21 | transition: { 22 | duration: 0.4, 23 | }, 24 | }, 25 | }; 26 | 27 | const ArrowLeftIcon = () => { 28 | const controls = useAnimation(); 29 | 30 | return ( 31 |
controls.start('animate')} 34 | onMouseLeave={() => controls.start('normal')} 35 | > 36 | 47 | 52 | 57 | 58 |
59 | ); 60 | }; 61 | 62 | export { ArrowLeftIcon }; 63 | -------------------------------------------------------------------------------- /components/layout/admin/nav-main.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { type LucideIcon } from 'lucide-react' 4 | import { Collapsible } from '~/components/ui/collapsible' 5 | import { 6 | SidebarGroup, 7 | SidebarGroupLabel, 8 | SidebarMenu, 9 | SidebarMenuButton, 10 | SidebarMenuItem, 11 | } from '~/components/ui/sidebar' 12 | import { useSession } from 'next-auth/react' 13 | import { usePathname } from 'next/navigation' 14 | import { useRouter } from 'next-nprogress-bar' 15 | 16 | export function NavMain({ 17 | items, 18 | }: { 19 | items: { 20 | title: string 21 | url: string 22 | icon?: LucideIcon 23 | isActive?: boolean 24 | items?: { 25 | title: string 26 | url: string 27 | }[] 28 | }[] 29 | }) { 30 | const { data: session, status } = useSession() 31 | const router = useRouter() 32 | const pathname = usePathname() 33 | const buttonClasses = 'active:scale-95 duration-200 ease-in-out' 34 | 35 | return ( 36 | 37 | 菜单 38 | 39 | {items.map((item) => ( 40 | 45 | 46 | router.push(item.url)}> 47 | {item.icon && } 48 | {item.title} 49 | 50 | 51 | 52 | ))} 53 | 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/icons/link.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Variants } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const pathVariants: Variants = { 7 | initial: { pathLength: 1, pathOffset: 0, rotate: 0 }, 8 | animate: { 9 | pathLength: [1, 0.97, 1, 0.97, 1], 10 | pathOffset: [0, 0.05, 0, 0.05, 0], 11 | rotate: [0, -5, 0], 12 | transition: { 13 | rotate: { 14 | duration: 0.5, 15 | }, 16 | duration: 1, 17 | times: [0, 0.2, 0.4, 0.6, 1], 18 | ease: 'easeInOut', 19 | }, 20 | }, 21 | }; 22 | 23 | const LinkIcon = () => { 24 | const controls = useAnimation(); 25 | 26 | return ( 27 |
controls.start('animate')} 30 | onMouseLeave={() => controls.start('normal')} 31 | > 32 | 43 | 48 | 53 | 54 |
55 | ); 56 | }; 57 | 58 | export { LinkIcon }; 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.18-alpine3.19 AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | 8 | WORKDIR /app 9 | 10 | COPY package.json pnpm-lock.yaml* .npmrc ./ 11 | 12 | RUN corepack enable pnpm && pnpm i --frozen-lockfile 13 | 14 | FROM base AS runner-base 15 | 16 | RUN apk add --no-cache libc6-compat 17 | 18 | WORKDIR /app 19 | 20 | RUN corepack enable pnpm && pnpm add prisma @prisma/client 21 | 22 | FROM base AS builder 23 | 24 | WORKDIR /app 25 | 26 | COPY --from=deps /app/node_modules ./node_modules 27 | COPY . . 28 | 29 | RUN AUTH_SECRET=pic-impact export NODE_OPTIONS=--openssl-legacy-provider && corepack enable pnpm && pnpm run prisma:generate && pnpm run build 30 | 31 | FROM base AS runner 32 | 33 | WORKDIR /app 34 | 35 | ENV NODE_ENV production 36 | 37 | RUN addgroup --system --gid 1001 nodejs 38 | RUN adduser --system --uid 1001 nextjs 39 | 40 | COPY --from=runner-base /app/node_modules ./node_modules 41 | COPY --from=builder /app/public ./public 42 | COPY ./prisma ./prisma 43 | COPY ./script.sh ./script.sh 44 | 45 | RUN chmod +x script.sh 46 | RUN mkdir .next 47 | RUN chown nextjs:nodejs .next 48 | 49 | # Automatically leverage output traces to reduce image size 50 | # https://nextjs.org/docs/advanced-features/output-file-tracing 51 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 52 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 53 | 54 | USER nextjs 55 | 56 | ENV AUTH_TRUST_HOST true 57 | ENV NODEJS_HELPERS 0 58 | 59 | EXPOSE 3000 60 | 61 | ENV PORT 3000 62 | 63 | CMD ./script.sh 64 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '~/server/auth' 2 | import { NextResponse } from 'next/server' 3 | 4 | export default auth((req) => { 5 | if (req.nextUrl.pathname.startsWith('/api/v1') && !req.auth) { 6 | return Response.json( 7 | { success: false, message: 'authentication failed' }, 8 | { status: 401 } 9 | ) 10 | } 11 | if (req.nextUrl.pathname.startsWith('/admin') && !req.auth) { 12 | return NextResponse.redirect(new URL('/login', req.url)) 13 | } 14 | if (req.auth && req.nextUrl.pathname === '/login') { 15 | return NextResponse.redirect(new URL('/', req.url)) 16 | } 17 | if (req.nextUrl.pathname.includes('/preview/')) { 18 | const startIndex = req.nextUrl.pathname.indexOf('/preview/') + '/preview/'.length; 19 | const contentAfterPreview = req.nextUrl.pathname.substring(startIndex); 20 | if (req.nextUrl.pathname.startsWith('/preview')) { 21 | const redirectUrl = new URL('/', req.url) 22 | redirectUrl.searchParams.set('id', String(contentAfterPreview)) 23 | return NextResponse.redirect(redirectUrl) 24 | } else { 25 | let endIndex = req.nextUrl.pathname.indexOf('/preview'); 26 | let contentBeforePreview = req.nextUrl.pathname.substring(0, endIndex); 27 | const redirectUrl = new URL(contentBeforePreview, req.url) 28 | redirectUrl.searchParams.set('id', String(contentAfterPreview)) 29 | return NextResponse.redirect(redirectUrl) 30 | } 31 | } 32 | }); 33 | 34 | // Optionally, don't invoke Middleware on some paths 35 | // Read more: https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher 36 | export const config = { 37 | matcher: [ 38 | "/((?!_next/static|_next/image|favicon.ico).*)", 39 | "/admin/:path*", 40 | '/api/v1/:path*', 41 | ], 42 | } -------------------------------------------------------------------------------- /components/icons/copy.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Transition } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const defaultTransition: Transition = { 7 | type: 'spring', 8 | stiffness: 160, 9 | damping: 17, 10 | mass: 1, 11 | }; 12 | 13 | const CopyIcon = () => { 14 | const controls = useAnimation(); 15 | 16 | return ( 17 |
controls.start('animate')} 20 | onMouseLeave={() => controls.start('normal')} 21 | > 22 | 33 | 47 | 56 | 57 |
58 | ); 59 | }; 60 | 61 | export { CopyIcon }; 62 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, ResolvingMetadata } from 'next' 2 | 3 | import { ThemeProvider } from '~/app/providers/next-ui-providers' 4 | import { ToasterProviders } from '~/app/providers/toaster-providers' 5 | import { SessionProviders } from '~/app/providers/session-providers' 6 | import { ProgressBarProviders } from '~/app/providers/progress-bar-providers' 7 | import { ButtonStoreProvider } from '~/app/providers/button-store-Providers' 8 | 9 | import '~/style/globals.css' 10 | import { fetchConfigsByKeys } from '~/server/db/query' 11 | 12 | type Props = { 13 | params: { id: string } 14 | searchParams: { [key: string]: string | string[] | undefined } 15 | } 16 | 17 | export async function generateMetadata( 18 | { params, searchParams }: Props, 19 | parent: ResolvingMetadata 20 | ): Promise { 21 | 22 | const data = await fetchConfigsByKeys([ 23 | 'custom_title', 24 | 'custom_favicon_url' 25 | ]) 26 | 27 | return { 28 | title: data?.find((item: any) => item.config_key === 'custom_title')?.config_value || 'PicImpact', 29 | icons: { icon: data?.find((item: any) => item.config_key === 'custom_favicon_url')?.config_value || './favicon.ico' }, 30 | } 31 | } 32 | 33 | export default function RootLayout({ 34 | children, 35 | }: Readonly<{ 36 | children: React.ReactNode; 37 | }>) { 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {children} 47 | 48 | 49 | 50 | 51 | 52 | 53 | ); 54 | } -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | 4 | import { cn } from '~/lib/utils' 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | export type DataProps = { 2 | data: any 3 | } 4 | 5 | export type HandleProps = { 6 | handle: () => any 7 | args: string 8 | } 9 | 10 | export type ImageServerHandleProps = { 11 | handle: (pageNum: number, tag: string) => any 12 | args: string 13 | totalHandle: (tag: string) => any 14 | } 15 | 16 | export type ImageHandleProps = { 17 | handle: (pageNum: number, tag: string) => any 18 | args: string 19 | album: string 20 | totalHandle: (tag: string) => any 21 | } 22 | 23 | export type LinkProps = { 24 | handle: () => any 25 | args: string 26 | data: any 27 | } 28 | 29 | export type AlbumType = { 30 | id: string; 31 | name: string; 32 | album_value: string; 33 | detail: string; 34 | show: number; 35 | sort: number; 36 | } 37 | 38 | export type ExifType = { 39 | make: any; 40 | model: any; 41 | bits: any; 42 | data_time: any; 43 | exposure_time: any; 44 | f_number: any; 45 | exposure_program: any; 46 | iso_speed_rating: any; 47 | focal_length: any; 48 | lens_specification: any; 49 | lens_model: any; 50 | exposure_mode: any; 51 | cfa_pattern: any; 52 | color_space: any; 53 | white_balance: any; 54 | } 55 | 56 | export type ImageType = { 57 | id: string; 58 | title: string; 59 | url: string; 60 | preview_url: string; 61 | video_url: string; 62 | exif: ExifType; 63 | labels: any; 64 | width: number; 65 | height: number; 66 | lon: string; 67 | lat: string; 68 | album: string; 69 | detail: string; 70 | type: number; 71 | show: number; 72 | sort: number; 73 | album_name: string; 74 | album_value: string; 75 | copyrights: any[]; 76 | } 77 | 78 | export type CopyrightType = { 79 | id: string; 80 | name: string; 81 | social_name: string; 82 | type: string; 83 | url: string; 84 | avatar_url: string; 85 | detail: string; 86 | default: number; 87 | show: number; 88 | } 89 | 90 | export type Config = { 91 | id: string; 92 | config_key: string; 93 | config_value: string; 94 | detail: string; 95 | } -------------------------------------------------------------------------------- /components/admin/upload/FileUploadHelpSheet.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '~/components/ui/sheet' 4 | import { useButtonStore } from '~/app/providers/button-store-Providers' 5 | 6 | export default function FileUploadHelpSheet() { 7 | const {uploadHelp, setUploadHelp} = useButtonStore( 8 | (state) => state, 9 | ) 10 | 11 | return ( 12 | { 16 | if (!open) { 17 | setUploadHelp(false) 18 | } 19 | }} 20 | modal={false} 21 | > 22 | 23 | 24 | 帮助 25 | 26 |

27 | 您在当前页面可以上传图片。 28 |

29 |

30 | 单文件上传模式: 31 | 选择好存储和相册后,选择文件或拖入文件到上传框,会自动上传文件到对应的存储。 32 | 同时以 0.2 倍率压缩为 webp 格式,生成一张预览用的图片。 33 | 同时上传完毕后,您可以编辑图片的一些信息,最后点击保存入库。 34 |

35 |

36 | LivePhoto 上传模式: 37 | 根据 Apple 官方文档描述:LivePhotos 由两部分组成:一张静态照片和一段拍摄前后瞬间的视频。 38 | 所以,您需要将 LivePhoto 导出为一个 JPG(或 HEIC) 文件和一个 MOV 文件,并分别上传。 39 |

40 |

41 | 多文件上传模式: 42 | 选择好存储和相册后,选择文件或拖入文件到上传框,会自动上传文件到对应的存储。 43 | 多文件上传模式下,无法在数据入库之前进行编辑,多文件上传属于全自动化上传,无需手动入库。 44 | 上传队列最大支持 5 个,上传完毕后您可以将图片从上传队列中删除。 45 | 重置按钮会重置存储和相册等数据。 46 |

47 |

48 | 注:文件上传时,会自动获取图片的宽高,请您勿随意更改,否则可能导致前端展示错位。 49 | 非必填项您可以在图片数据入库后,去图片维护里面进行编辑。 50 |

51 |

52 | 注:部分云平台,限制了上传请求的主体大小,比如 Vercel 的免费用户限制 6M。 53 |

54 |

55 | 如您有更多疑问欢迎反馈! 56 |

57 |
58 |
59 |
60 |
61 | ) 62 | } -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "~/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /hono/auth.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { queryAuthTemplateSecret } from '~/server/db/query' 3 | import * as OTPAuth from 'otpauth' 4 | import { deleteAuthSecret, saveAuthSecret, saveAuthTemplateSecret } from '~/server/db/operate' 5 | import { Hono } from 'hono' 6 | 7 | const app = new Hono() 8 | 9 | app.get('/get-seed-secret', async (c) => { 10 | try { 11 | let secret = new OTPAuth.Secret({ size: 12 }); 12 | 13 | let totp = new OTPAuth.TOTP({ 14 | issuer: "PicImpact", 15 | label: "admin", 16 | algorithm: "SHA512", 17 | digits: 6, 18 | period: 30, 19 | secret: secret, 20 | }); 21 | 22 | await saveAuthTemplateSecret(secret.base32); 23 | 24 | return c.json({ 25 | code: 200, 26 | message: '令牌颁发成功!', 27 | data: { 28 | uri: totp.toString(), 29 | secret: secret.base32 30 | } 31 | }) 32 | } catch (e) { 33 | console.log(e) 34 | return c.json({ code: 500, message: '令牌颁发失败!' }) 35 | } 36 | }) 37 | 38 | app.post('/validate', async (c) => { 39 | const data = await c.req.json() 40 | try { 41 | const secret = await queryAuthTemplateSecret(); 42 | let totp = new OTPAuth.TOTP({ 43 | issuer: "PicImpact", 44 | label: "admin", 45 | algorithm: "SHA512", 46 | digits: 6, 47 | period: 30, 48 | // @ts-ignore 49 | secret: OTPAuth.Secret.fromBase32(secret?.config_value), 50 | }); 51 | let delta = totp.validate({ token: data.token, window: 1 }) 52 | if (delta === 0) { 53 | // @ts-ignore 54 | await saveAuthSecret('true', secret?.config_value) 55 | return c.json({ code: 200, message: '设置成功!' }) 56 | } 57 | return c.json({ code: 500, message: '设置失败!' }) 58 | } catch (e) { 59 | console.log(e) 60 | return c.json({ code: 500, message: '设置失败!' }) 61 | } 62 | }) 63 | 64 | app.delete('/remove', async (c) => { 65 | try { 66 | await deleteAuthSecret(); 67 | return c.json({ code: 200, message: '移除成功!' }) 68 | } catch (e) { 69 | console.log(e) 70 | return c.json({ code: 500, message: '移除失败!' }) 71 | } 72 | }) 73 | 74 | export default app -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | 5 | import { cn } from '~/lib/utils' 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '~/lib/utils' 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /components/icons/delete.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { Variants } from 'framer-motion' 4 | import { motion, useAnimation } from 'framer-motion' 5 | 6 | const lidVariants: Variants = { 7 | normal: { y: 0 }, 8 | animate: { y: -1.1 }, 9 | }; 10 | 11 | const springTransition = { 12 | type: 'spring', 13 | stiffness: 500, 14 | damping: 30, 15 | }; 16 | 17 | const DeleteIcon = () => { 18 | const controls = useAnimation(); 19 | 20 | return ( 21 |
controls.start('animate')} 24 | onMouseLeave={() => controls.start('normal')} 25 | > 26 | 37 | 42 | 43 | 44 | 45 | 54 | 66 | 78 | 79 |
80 | ); 81 | }; 82 | 83 | export { DeleteIcon }; 84 | -------------------------------------------------------------------------------- /components/animata/text/counter.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { useInView, useMotionValue, useSpring } from 'framer-motion' 3 | 4 | import { cn } from '~/lib/utils' 5 | 6 | interface CounterProps { 7 | /** 8 | * A function to format the counter value. By default, it will format the 9 | * number with commas. 10 | */ 11 | format?: (value: number) => string; 12 | 13 | /** 14 | * The target value of the counter. 15 | */ 16 | targetValue: number; 17 | 18 | /** 19 | * The direction of the counter. If "up", the counter will start from 0 and 20 | * go up to the target value. If "down", the counter will start from the target 21 | * value and go down to 0. 22 | */ 23 | direction?: "up" | "down"; 24 | 25 | /** 26 | * The delay in milliseconds before the counter starts counting. 27 | */ 28 | delay?: number; 29 | 30 | /** 31 | * Additional classes for the counter. 32 | */ 33 | className?: string; 34 | } 35 | 36 | export const Formatter = { 37 | number: (value: number) => Intl.NumberFormat("en-US").format(+value.toFixed(0)), 38 | currency: (value: number) => 39 | Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(+value.toFixed(0)), 40 | }; 41 | 42 | export default function Counter({ 43 | format = Formatter.number, 44 | targetValue, 45 | direction = "up", 46 | delay = 0, 47 | className, 48 | }: CounterProps) { 49 | const ref = useRef(null); 50 | const isGoingUp = direction === "up"; 51 | const motionValue = useMotionValue(isGoingUp ? 0 : targetValue); 52 | 53 | const springValue = useSpring(motionValue, { 54 | damping: 60, 55 | stiffness: 80, 56 | }); 57 | const isInView = useInView(ref, { margin: "0px", once: true }); 58 | 59 | useEffect(() => { 60 | if (!isInView) { 61 | return; 62 | } 63 | 64 | const timer = setTimeout(() => { 65 | motionValue.set(isGoingUp ? targetValue : 0); 66 | }, delay); 67 | 68 | return () => clearTimeout(timer); 69 | }, [isInView, delay, isGoingUp, targetValue, motionValue]); 70 | 71 | useEffect(() => { 72 | springValue.on("change", (value) => { 73 | if (ref.current) { 74 | // @ts-ignore 75 | ref.current.textContent = format ? format(value) : value; 76 | } 77 | }); 78 | }, [springValue, format]); 79 | 80 | return ; 81 | } 82 | -------------------------------------------------------------------------------- /components/ui/input-otp.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { OTPInput, OTPInputContext } from 'input-otp' 5 | import { Dot } from 'lucide-react' 6 | 7 | import { cn } from '~/lib/utils' 8 | 9 | const InputOTP = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, containerClassName, ...props }, ref) => ( 13 | 22 | )) 23 | InputOTP.displayName = "InputOTP" 24 | 25 | const InputOTPGroup = React.forwardRef< 26 | React.ElementRef<"div">, 27 | React.ComponentPropsWithoutRef<"div"> 28 | >(({ className, ...props }, ref) => ( 29 |
30 | )) 31 | InputOTPGroup.displayName = "InputOTPGroup" 32 | 33 | const InputOTPSlot = React.forwardRef< 34 | React.ElementRef<"div">, 35 | React.ComponentPropsWithoutRef<"div"> & { index: number } 36 | >(({ index, className, ...props }, ref) => { 37 | const inputOTPContext = React.useContext(OTPInputContext) 38 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] 39 | 40 | return ( 41 |
50 | {char} 51 | {hasFakeCaret && ( 52 |
53 |
54 |
55 | )} 56 |
57 | ) 58 | }) 59 | InputOTPSlot.displayName = "InputOTPSlot" 60 | 61 | const InputOTPSeparator = React.forwardRef< 62 | React.ElementRef<"div">, 63 | React.ComponentPropsWithoutRef<"div"> 64 | >(({ ...props }, ref) => ( 65 |
66 | 67 |
68 | )) 69 | InputOTPSeparator.displayName = "InputOTPSeparator" 70 | 71 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } 72 | -------------------------------------------------------------------------------- /server/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth' 2 | import { PrismaAdapter } from '@auth/prisma-adapter' 3 | import CredentialsProvider from 'next-auth/providers/credentials' 4 | import { db } from '~/server/lib/db' 5 | import { z } from 'zod' 6 | import CryptoJS from 'crypto-js' 7 | import { fetchSecretKey } from '~/server/db/query' 8 | 9 | export const { handlers, auth, signIn, signOut } = NextAuth({ 10 | secret: process.env.AUTH_SECRET || 'pic-impact', 11 | adapter: PrismaAdapter(db), 12 | pages: { 13 | signIn: '/login', 14 | }, 15 | session: { 16 | strategy: "jwt", 17 | }, 18 | providers: [ 19 | CredentialsProvider({ 20 | id: "Credentials", 21 | name: "Credentials", 22 | credentials: { 23 | email: { label: "email", type: "email", placeholder: "example@qq.com" }, 24 | password: { label: "Password", type: "password" } 25 | }, 26 | async authorize(credentials, req) { 27 | const parsedCredentials = z 28 | .object({ email: z.string().email(), password: z.string().min(6) }) 29 | .safeParse(credentials); 30 | 31 | if (parsedCredentials.success) { 32 | const { email, password } = parsedCredentials.data; 33 | 34 | const user = await db.user.findFirst({ 35 | where: { 36 | email: email, 37 | }, 38 | }) 39 | 40 | const secretKey = await fetchSecretKey() 41 | 42 | if (secretKey && secretKey.config_value) { 43 | const hashedPassword = CryptoJS.HmacSHA512(password, secretKey?.config_value).toString() 44 | 45 | if (user && hashedPassword === user.password) { 46 | return user; 47 | } 48 | } 49 | } 50 | 51 | return null 52 | } 53 | }) 54 | ], 55 | callbacks: { 56 | async jwt({ token, user, account, profile }) { 57 | if (user) { 58 | token.id = user.id 59 | token.name = user.name 60 | token.email = user.email 61 | token.image = user.image 62 | } 63 | return token 64 | }, 65 | async session({ token, session }) { 66 | if (token) { 67 | // @ts-ignore 68 | session.user.id = token.id 69 | // @ts-ignore 70 | session.user.name = token.name 71 | // @ts-ignore 72 | session.user.email = token.email 73 | // @ts-ignore 74 | session.user.image = token.image 75 | } 76 | 77 | return session 78 | }, 79 | } 80 | }) -------------------------------------------------------------------------------- /app/rss.xml/route.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import RSS from 'rss' 3 | import { fetchConfigsByKeys, getRSSImages } from '~/server/db/query' 4 | 5 | export async function GET(request: Request) { 6 | const data = await fetchConfigsByKeys([ 7 | 'custom_title', 8 | 'custom_author', 9 | 'rss_feed_id', 10 | 'rss_user_id' 11 | ]) 12 | 13 | const url = new URL(request.url); 14 | 15 | const feedId = data?.find((item: any) => item.config_key === 'rss_feed_id')?.config_value?.toString(); 16 | const userId = data?.find((item: any) => item.config_key === 'rss_user_id')?.config_value?.toString(); 17 | 18 | const customElements = feedId && userId 19 | ? [ 20 | { 21 | follow_challenge: [ 22 | { feedId: feedId }, 23 | { userId: userId } 24 | ] 25 | } 26 | ] 27 | : []; 28 | 29 | const feed = new RSS({ 30 | title: data?.find((item: any) => item.config_key === 'custom_title')?.config_value?.toString() || '相册', 31 | generator: 'RSS for Next.js', 32 | feed_url: `${url.origin}/rss.xml`, 33 | site_url: url.origin, 34 | copyright: `© 2024${new Date().getFullYear().toString() === '2024' ? '' : `-${new Date().getFullYear().toString()}`} ${ 35 | data?.find((item: any) => item.config_key === 'custom_author')?.config_value?.toString() || '' 36 | }.`, 37 | pubDate: new Date().toUTCString(), 38 | ttl: 60, 39 | custom_elements: customElements, 40 | }); 41 | 42 | const images = await getRSSImages() 43 | if (Array.isArray(images) && images.length > 0) { 44 | images?.map(item => { 45 | feed.item({ 46 | title: item.title || '图片', 47 | description: ` 48 |
49 | ${item.detail} 50 |

${item.detail}

51 | 查看图片信息 52 |
53 | `, 54 | url: url.origin + (item.album_value === '/' ? '/preview/' : item.album_value + '/preview/') + item.id, 55 | guid: item.id, 56 | date: item.created_at, 57 | enclosure: { 58 | url: item.preview_url || item.url, 59 | type: 'image/jpeg', 60 | }, 61 | media: { 62 | content: { 63 | url: item.preview_url || item.url, 64 | type: 'image/jpeg', 65 | }, 66 | thumbnail: { 67 | url: item.preview_url || item.url, 68 | }, 69 | }, 70 | }) 71 | }) 72 | } 73 | 74 | return new Response(feed.xml(), { 75 | headers: { 76 | 'Content-Type': 'application/xml', 77 | } 78 | }); 79 | } -------------------------------------------------------------------------------- /components/BlurImage.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState } from 'react' 4 | import { useButtonStore } from '~/app/providers/button-store-Providers' 5 | import { cn } from '~/lib/utils' 6 | 7 | export default function BlurImage({ photo, dataList }: { photo: any, dataList: any }) { 8 | const { setMasonryView, setMasonryViewData, setMasonryViewDataList } = useButtonStore( 9 | (state) => state, 10 | ) 11 | 12 | const [loaded, setLoaded] = useState(false) 13 | 14 | return ( 15 |
16 | {photo.alt} { 23 | setMasonryView(true) 24 | setMasonryViewData(photo) 25 | setMasonryViewDataList(dataList) 26 | }} 27 | onLoad={() => setLoaded(true)} 28 | className={cn( 29 | "duration-700 ease-[cubic-bezier(0.4, 0, 0.2, 1)] group-hover:opacity-75 cursor-pointer transition-all will-change-transform hover:scale-[1.01]", 30 | { 31 | 'opacity-100 scale-100 blur-0': loaded, 32 | 'opacity-0 scale-95 blur-sm': !loaded, 33 | } 34 | )} 35 | /> 36 | { 37 | photo.type === 2 && 38 |
39 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 | } 62 |
63 | ) 64 | } -------------------------------------------------------------------------------- /hono/images.ts: -------------------------------------------------------------------------------- 1 | import 'server-only' 2 | import { 3 | deleteBatchImage, 4 | deleteImage, 5 | insertImage, 6 | updateImage, 7 | updateImageShow, 8 | updateImageAlbum 9 | } from '~/server/db/operate' 10 | import { Hono } from 'hono' 11 | 12 | const app = new Hono() 13 | 14 | app.post('/add', async (c) => { 15 | const image = await c.req.json() 16 | if (!image.url) { 17 | return c.json({ 18 | code: 500, 19 | message: '图片链接不能为空!' 20 | }) 21 | } 22 | if (!image.height || image.height <= 0) { 23 | return c.json({ 24 | code: 500, 25 | message: '图片高度不能为空且必须大于 0!' 26 | }) 27 | } 28 | if (!image.width || image.width <= 0) { 29 | return c.json({ 30 | code: 500, 31 | message: '图片宽度不能为空且必须大于 0!' 32 | }) 33 | } 34 | try { 35 | await insertImage(image); 36 | return c.json({ code: 200, message: '保存成功!' }) 37 | } catch (e) { 38 | console.log(e) 39 | return c.json({ code: 500, message: '保存失败!' }) 40 | } 41 | }) 42 | 43 | app.delete('/batch-delete', async (c) => { 44 | try { 45 | const data = await c.req.json() 46 | await deleteBatchImage(data); 47 | return c.json({ code: 200, message: '删除成功!' }) 48 | } catch (e) { 49 | console.log(e) 50 | return c.json({ code: 500, message: '删除失败!' }) 51 | } 52 | }) 53 | 54 | app.delete('/delete/:id', async (c) => { 55 | try { 56 | const { id } = c.req.param() 57 | await deleteImage(id); 58 | return c.json({ code: 200, message: '删除成功!' }) 59 | } catch (e) { 60 | console.log(e) 61 | return c.json({ code: 500, message: '删除失败!' }) 62 | } 63 | }) 64 | 65 | app.put('/update', async (c) => { 66 | const image = await c.req.json() 67 | if (!image.url) { 68 | return c.json({ 69 | code: 500, 70 | message: '图片链接不能为空!' 71 | }) 72 | } 73 | if (!image.height || image.height <= 0) { 74 | return c.json({ 75 | code: 500, 76 | message: '图片高度不能为空且必须大于 0!' 77 | }) 78 | } 79 | if (!image.width || image.width <= 0) { 80 | return c.json({ 81 | code: 500, 82 | message: '图片宽度不能为空且必须大于 0!' 83 | }) 84 | } 85 | try { 86 | await updateImage(image); 87 | return c.json({ code: 200, message: '更新成功!' }) 88 | } catch (e) { 89 | console.log(e) 90 | return c.json({ code: 500, message: '更新失败!' }) 91 | } 92 | }) 93 | 94 | app.put('/update-show', async (c) => { 95 | const image = await c.req.json() 96 | const data = await updateImageShow(image.id, image.show); 97 | return c.json(data) 98 | }) 99 | 100 | app.put('/update-Album', async (c) => { 101 | const image = await c.req.json() 102 | try { 103 | await updateImageAlbum(image.imageId, image.albumId); 104 | return c.json({ 105 | code: 200, 106 | message: '更新成功!' 107 | }) 108 | } catch (e) { 109 | console.log(e) 110 | return c.json({ 111 | code: 500, 112 | message: '更新失败!' 113 | }) 114 | } 115 | }) 116 | 117 | export default app -------------------------------------------------------------------------------- /components/admin/settings/storages/S3Tabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Card } from '~/components/ui/card' 4 | import { 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableHead, 9 | TableHeader, 10 | TableRow, 11 | } from "~/components/ui/table" 12 | import { Button } from '~/components/ui/button' 13 | import { ReloadIcon } from '@radix-ui/react-icons' 14 | import React from 'react' 15 | import useSWR from 'swr' 16 | import { fetcher } from '~/lib/utils/fetcher' 17 | import { toast } from 'sonner' 18 | import { useButtonStore } from '~/app/providers/button-store-Providers' 19 | import S3EditSheet from '~/components/admin/settings/storages/S3EditSheet' 20 | 21 | export default function S3Tabs() { 22 | const { data, error, isValidating, mutate } = useSWR('/api/v1/settings/s3-info', fetcher 23 | , { revalidateOnFocus: false }) 24 | const { setS3Edit, setS3EditData } = useButtonStore( 25 | (state) => state, 26 | ) 27 | 28 | if (error) { 29 | toast.error('请求失败!') 30 | } 31 | 32 | return ( 33 |
34 | 35 |
36 |
37 |
38 |

S3 配置

39 |
40 |
41 |
42 | 52 | 63 |
64 |
65 | 66 | { 67 | data && 68 | 69 | 70 | 71 | 72 | Key 73 | Value 74 | 75 | 76 | 77 | {data.map((item: any) => ( 78 | 79 | {item.config_key} 80 | {item.config_value || 'N&A'} 81 | 82 | ))} 83 | 84 |
85 |
86 | } 87 | {Array.isArray(data) && data.length > 0 && } 88 |
89 | ) 90 | } -------------------------------------------------------------------------------- /components/admin/settings/storages/R2Tabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Card } from '~/components/ui/card' 4 | import { 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableHead, 9 | TableHeader, 10 | TableRow, 11 | } from "~/components/ui/table" 12 | import { Button } from '~/components/ui/button' 13 | import { ReloadIcon } from '@radix-ui/react-icons' 14 | import React from 'react' 15 | import useSWR from 'swr' 16 | import { fetcher } from '~/lib/utils/fetcher' 17 | import { toast } from 'sonner' 18 | import { useButtonStore } from '~/app/providers/button-store-Providers' 19 | import R2EditSheet from '~/components/admin/settings/storages/R2EditSheet' 20 | 21 | export default function R2Tabs() { 22 | const { data, error, isValidating, mutate } = useSWR('/api/v1/settings/r2-info', fetcher 23 | , { revalidateOnFocus: false }) 24 | const { setR2Edit, setR2EditData } = useButtonStore( 25 | (state) => state, 26 | ) 27 | 28 | if (error) { 29 | toast.error('请求失败!') 30 | } 31 | 32 | return ( 33 |
34 | 35 |
36 |
37 |
38 |

Cloudflare R2 配置

39 |
40 |
41 |
42 | 52 | 63 |
64 |
65 | 66 | { 67 | data && 68 | 69 | 70 | 71 | 72 | Key 73 | Value 74 | 75 | 76 | 77 | {data.map((item: any) => ( 78 | 79 | {item.config_key} 80 | {item.config_value || 'N&A'} 81 | 82 | ))} 83 | 84 |
85 |
86 | } 87 | {Array.isArray(data) && data.length > 0 && } 88 |
89 | ) 90 | } -------------------------------------------------------------------------------- /components/admin/settings/storages/AListTabs.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Card } from '~/components/ui/card' 4 | import { 5 | Table, 6 | TableBody, 7 | TableCell, 8 | TableHead, 9 | TableHeader, 10 | TableRow, 11 | } from "~/components/ui/table" 12 | import useSWR from 'swr' 13 | import { fetcher } from '~/lib/utils/fetcher' 14 | import { toast } from 'sonner' 15 | import { useButtonStore } from '~/app/providers/button-store-Providers' 16 | import { Button } from '~/components/ui/button' 17 | import { ReloadIcon } from '@radix-ui/react-icons' 18 | import React from 'react' 19 | import AListEditSheet from '~/components/admin/settings/storages/AListEditSheet' 20 | 21 | export default function AListTabs() { 22 | const { data, error, isValidating, mutate } = useSWR('/api/v1/storage/alist/info', fetcher 23 | , { revalidateOnFocus: false }) 24 | const { setAListEdit, setAListEditData } = useButtonStore( 25 | (state) => state, 26 | ) 27 | 28 | if (error) { 29 | toast.error('请求失败!') 30 | } 31 | 32 | return ( 33 |
34 | 35 |
36 |
37 |
38 |

AList 配置

39 |
40 |
41 |
42 | 52 | 63 |
64 |
65 |
66 | { 67 | data && 68 | 69 | 70 | 71 | 72 | Key 73 | Value 74 | 75 | 76 | 77 | {data.map((item: any) => ( 78 | 79 | {item.config_key} 80 | {item.config_value || 'N&A'} 81 | 82 | ))} 83 | 84 |
85 |
86 | } 87 | {Array.isArray(data) && data.length > 0 && } 88 |
89 | ) 90 | } -------------------------------------------------------------------------------- /components/layout/admin/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | Copyright, 6 | Earth, 7 | Image, 8 | ImageUp, 9 | Info, 10 | Frame, 11 | ServerCog, 12 | Milestone, 13 | RectangleEllipsis, 14 | SquareTerminal, 15 | ShieldCheck, 16 | } from 'lucide-react' 17 | 18 | import { NavMain } from '~/components/layout/admin/nav-main' 19 | import { NavProjects } from '~/components/layout/admin/nav-projects' 20 | import { NavUser } from '~/components/layout/admin/nav-user' 21 | import { NavTitle } from '~/components/layout/admin/nav-title' 22 | import { 23 | Sidebar, 24 | SidebarContent, 25 | SidebarFooter, 26 | SidebarGroup, 27 | SidebarHeader, 28 | SidebarMenu, 29 | SidebarMenuButton, 30 | SidebarMenuItem, 31 | SidebarRail, 32 | } from '~/components/ui/sidebar' 33 | import { useRouter } from 'next-nprogress-bar' 34 | 35 | const data = { 36 | navMain: [ 37 | { 38 | title: "控制台", 39 | url: "/admin", 40 | icon: SquareTerminal, 41 | }, 42 | { 43 | title: "上传", 44 | url: "/admin/upload", 45 | icon: ImageUp, 46 | }, 47 | { 48 | title: "图片维护", 49 | url: "/admin/list", 50 | icon: Image, 51 | }, 52 | { 53 | title: "相册管理", 54 | url: "/admin/album", 55 | icon: Milestone, 56 | }, 57 | { 58 | title: "版权管理", 59 | url: "/admin/copyright", 60 | icon: Copyright, 61 | }, 62 | { 63 | title: "关于", 64 | url: "/admin/about", 65 | icon: Info, 66 | }, 67 | ], 68 | projects: { 69 | title: '设置', 70 | items: [ 71 | { 72 | name: "首选项", 73 | url: "/admin/settings/preferences", 74 | icon: Frame, 75 | }, 76 | { 77 | name: "密码修改", 78 | url: "/admin/settings/password", 79 | icon: RectangleEllipsis , 80 | }, 81 | { 82 | name: "存储配置", 83 | url: "/admin/settings/storages", 84 | icon: ServerCog, 85 | }, 86 | { 87 | name: "双因素验证", 88 | url: "/admin/settings/authenticator", 89 | icon: ShieldCheck, 90 | }, 91 | ], 92 | }, 93 | } 94 | 95 | export function AppSidebar({ ...props }: React.ComponentProps) { 96 | const router = useRouter() 97 | const iconClasses = 'text-xl text-default-500 pointer-events-none flex-shrink-0' 98 | 99 | return ( 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | router.push('/')}> 111 | 112 | 回到首页 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons' 3 | import { Slot } from '@radix-ui/react-slot' 4 | 5 | import { cn } from '~/lib/utils' 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>