mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
fix(docker): make the site icon configurable at runtime
Add SITE_ICON_FILE and SITE_ICON_URL support for the Docker image, route the app shell and manifest through a runtime icon endpoint, document the new Docker branding flow, and bump the app version to 4.8.3.
This commit is contained in:
@@ -378,15 +378,6 @@ docker run -d -p 3000:3000 \
|
||||
- 变量名:`NEXT_PUBLIC_SITE_NAME`
|
||||
- 变量值:`我的视频平台`
|
||||
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e NEXT_PUBLIC_SITE_NAME="我的视频平台" \
|
||||
-e NEXT_PUBLIC_SITE_TITLE="我的视频 - 聚合播放平台" \
|
||||
-e NEXT_PUBLIC_SITE_DESCRIPTION="专属视频聚合播放平台" \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**本地开发:**
|
||||
在项目根目录创建 `.env.local` 文件:
|
||||
```env
|
||||
@@ -395,6 +386,44 @@ NEXT_PUBLIC_SITE_TITLE=我的视频 - 聚合播放平台
|
||||
NEXT_PUBLIC_SITE_DESCRIPTION=专属视频聚合播放平台
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `NEXT_PUBLIC_SITE_*` 属于构建时变量。直接运行 Docker Hub 的预构建镜像时,`docker run -e NEXT_PUBLIC_SITE_* ...` 不会覆盖已经打包进前端的文案。
|
||||
|
||||
## Docker 图标自定义
|
||||
|
||||
Docker 预构建镜像支持在运行时替换图标,无需重新构建镜像。该配置会同时作用于顶部 Logo、浏览器 favicon 和 PWA 图标。
|
||||
|
||||
### 可用环境变量:
|
||||
|
||||
| 变量名 | 说明 |
|
||||
|--------|------|
|
||||
| `SITE_ICON_FILE` | 从容器内文件路径读取图标,适合 Docker 挂载,优先级高于 `SITE_ICON_URL` |
|
||||
| `SITE_ICON_URL` | 直接使用外部 URL 或站内路径作为图标 |
|
||||
|
||||
### 配置示例:
|
||||
|
||||
**Docker 挂载文件(推荐):**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-v /path/to/icon.png:/app/custom/icon.png:ro \
|
||||
-e SITE_ICON_FILE=/app/custom/icon.png \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**Docker 使用 URL:**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e SITE_ICON_URL="https://example.com/icon.png" \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**Docker 使用站内路径:**
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e SITE_ICON_URL="/placeholder-poster.svg" \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
## 自动订阅源配置
|
||||
|
||||
可以通过环境变量自动配置订阅源,应用启动时会自动加载并设置为自动更新。
|
||||
@@ -632,6 +661,8 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
| `NEXT_PUBLIC_SITE_TITLE` | 浏览器标签页标题 | `KVideo - 视频聚合平台` |
|
||||
| `NEXT_PUBLIC_SITE_DESCRIPTION` | 站点描述 | `视频聚合平台` |
|
||||
| `NEXT_PUBLIC_SITE_NAME` | 站点头部名称 | `KVideo` |
|
||||
| `SITE_ICON_FILE` | Docker 运行时图标文件路径(优先于 `SITE_ICON_URL`) | - |
|
||||
| `SITE_ICON_URL` | Docker 运行时图标 URL 或站内路径 | - |
|
||||
| `SUBSCRIPTION_SOURCES` | 自动订阅源配置(服务端) | - |
|
||||
| `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` | 自动订阅源配置(客户端) | - |
|
||||
| `IPTV_SOURCES` / `NEXT_PUBLIC_IPTV_SOURCES` | IPTV 直播源配置 | - |
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const DEFAULT_ICON_PATH = '/icon.png';
|
||||
const SITE_ICON_ROUTE = '/api/site-icon';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
switch (path.extname(filePath).toLowerCase()) {
|
||||
case '.avif':
|
||||
return 'image/avif';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function getIconFileCandidates(filePath: string): string[] {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return [filePath];
|
||||
}
|
||||
|
||||
const currentWorkingDirectory = process.cwd();
|
||||
const candidates = [path.join(currentWorkingDirectory, filePath)];
|
||||
const standaloneSuffix = `${path.sep}.next${path.sep}standalone`;
|
||||
|
||||
if (currentWorkingDirectory.endsWith(standaloneSuffix)) {
|
||||
candidates.push(path.resolve(currentWorkingDirectory, '..', '..', filePath));
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function buildIconRedirect(request: Request, iconUrl: string): NextResponse | null {
|
||||
try {
|
||||
const origin = new URL(request.url).origin;
|
||||
const resolvedUrl = new URL(iconUrl, `${origin}/`);
|
||||
|
||||
if (resolvedUrl.pathname === SITE_ICON_ROUTE) {
|
||||
console.warn('[SiteIcon] Ignoring SITE_ICON_URL because it points back to /api/site-icon.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return NextResponse.redirect(resolvedUrl, 307);
|
||||
} catch (error) {
|
||||
console.warn('[SiteIcon] Invalid SITE_ICON_URL:', iconUrl, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function serveIconFile(filePath: string): Promise<Response> {
|
||||
const [fileBuffer, fileStat] = await Promise.all([
|
||||
fs.promises.readFile(filePath),
|
||||
fs.promises.stat(filePath),
|
||||
]);
|
||||
const etag = `W/"${fileStat.size}-${Math.trunc(fileStat.mtimeMs)}"`;
|
||||
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=0, must-revalidate',
|
||||
'Content-Length': String(fileStat.size),
|
||||
'Content-Type': getMimeType(filePath),
|
||||
ETag: etag,
|
||||
'Last-Modified': fileStat.mtime.toUTCString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const iconFile = process.env.SITE_ICON_FILE?.trim();
|
||||
|
||||
if (iconFile) {
|
||||
const resolvedFilePaths = getIconFileCandidates(iconFile);
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const resolvedFilePath of resolvedFilePaths) {
|
||||
try {
|
||||
return await serveIconFile(resolvedFilePath);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[SiteIcon] Failed to read SITE_ICON_FILE from any supported path: ${resolvedFilePaths.join(', ')}`,
|
||||
lastError,
|
||||
);
|
||||
}
|
||||
|
||||
const iconUrl = process.env.SITE_ICON_URL?.trim() || process.env.NEXT_PUBLIC_SITE_ICON_URL?.trim();
|
||||
|
||||
if (iconUrl) {
|
||||
const redirectResponse = buildIconRedirect(request, iconUrl);
|
||||
|
||||
if (redirectResponse) {
|
||||
return redirectResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL(DEFAULT_ICON_PATH, request.url), 307);
|
||||
}
|
||||
|
||||
export const HEAD = GET;
|
||||
+3
-3
@@ -8,7 +8,7 @@ import { TVNavigationInitializer } from "@/components/TVNavigationInitializer";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
|
||||
import { PasswordGate } from "@/components/PasswordGate";
|
||||
import { siteConfig } from "@/lib/config/site-config";
|
||||
import { siteConfig, SITE_ICON_PATH } from "@/lib/config/site-config";
|
||||
import { AdKeywordsInjector } from "@/components/AdKeywordsInjector";
|
||||
import { BackToTop } from "@/components/ui/BackToTop";
|
||||
import { ScrollPositionManager } from "@/components/ScrollPositionManager";
|
||||
@@ -65,7 +65,7 @@ export const metadata: Metadata = {
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.description,
|
||||
icons: {
|
||||
icon: '/icon.png',
|
||||
icon: SITE_ICON_PATH,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function RootLayout({
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="KVideo" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
<link rel="apple-touch-icon" href={SITE_ICON_PATH} />
|
||||
{/* Theme Color (for browser address bar) */}
|
||||
<meta name="theme-color" content="#000000" />
|
||||
{/* Mobile viewport */}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { siteConfig } from '@/lib/config/site-config';
|
||||
import { siteConfig, SITE_ICON_PATH } from '@/lib/config/site-config';
|
||||
import { getSession, clearSession, hasPermission, type AuthSession } from '@/lib/store/auth-store';
|
||||
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
||||
import { LogOut } from 'lucide-react';
|
||||
@@ -45,10 +45,11 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
|
||||
>
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
|
||||
<Image
|
||||
src="/icon.png"
|
||||
src={SITE_ICON_PATH}
|
||||
alt={siteConfig.name}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Image from 'next/image';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { siteConfig } from '@/lib/config/site-config';
|
||||
import { siteConfig, SITE_ICON_PATH } from '@/lib/config/site-config';
|
||||
|
||||
export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
|
||||
const router = useRouter();
|
||||
@@ -20,10 +20,11 @@ export function PlayerNavbar({ isPremium }: { isPremium?: boolean }) {
|
||||
title={isPremium ? "返回高级主页" : "返回首页"}
|
||||
>
|
||||
<Image
|
||||
src="/icon.png"
|
||||
src={SITE_ICON_PATH}
|
||||
alt={siteConfig.name}
|
||||
width={40}
|
||||
height={40}
|
||||
unoptimized
|
||||
className="object-contain"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface SiteConfig {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const SITE_ICON_PATH = "/api/site-icon";
|
||||
|
||||
/**
|
||||
* Site configuration object
|
||||
* Uses environment variables with fallback to default values
|
||||
@@ -18,4 +20,4 @@ export const siteConfig: SiteConfig = {
|
||||
title: process.env.NEXT_PUBLIC_SITE_TITLE || "KVideo - 视频聚合平台",
|
||||
description: process.env.NEXT_PUBLIC_SITE_DESCRIPTION || "视频聚合平台",
|
||||
name: process.env.NEXT_PUBLIC_SITE_NAME || "KVideo",
|
||||
};
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port ${PORT:-3000}",
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"src": "/api/site-icon",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
@@ -23,4 +21,4 @@
|
||||
"dir": "ltr",
|
||||
"scope": "/",
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user