feat: add Redis caching for follower counts, implement fetch timeouts, enable Nginx gzip compression, and refactor icon management.

This commit is contained in:
2026-06-29 21:58:13 +07:00 Unverified
parent d0ce59b209
commit 14abb54108
10 changed files with 108 additions and 32 deletions
+2 -14
View File
@@ -3,7 +3,6 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { config, config_p2 } from "@/lib/config";
@@ -18,8 +17,7 @@ export function LinkCard({ counts }: { counts: FollowerCounts }) {
<div key={isPage2 ? "p2" : "p1"} className="animate-in fade-in slide-in-from-bottom-2 duration-200">
<nav aria-label="ลิ้งค์โซเชียลมีเดีย" className="flex flex-col gap-3">
{currentConfig.links.map((item, index) => {
const isStringIcon = typeof item.icon === "string";
const IconComponent = !isStringIcon ? item.icon : null;
const IconComponent = item.icon;
const count = getCountForLink(item.name, counts);
@@ -34,17 +32,7 @@ export function LinkCard({ counts }: { counts: FollowerCounts }) {
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div className="shrink-0 w-8 h-8 flex items-center justify-center">
{isStringIcon ? (
<Image
src={item.icon as string}
alt={`${item.name} icon`}
width={24}
height={24}
className="object-contain"
/>
) : (
IconComponent && <IconComponent className="w-6 h-6 text-zinc-300" />
)}
<IconComponent className="w-6 h-6 text-zinc-300" />
</div>
<div className="flex flex-col items-start text-left">
<span className="font-medium text-zinc-100">{item.name}</span>
+2 -4
View File
@@ -1,15 +1,13 @@
services:
erika:
build: .
expose:
- "3000"
network_mode: host
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: erika-nginx
ports:
- "8606:80"
network_mode: host
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
+8 -7
View File
@@ -1,10 +1,11 @@
import { Gamepad2, UserCircle, MessageSquare, LucideIcon } from "lucide-react";
import type { ComponentType, SVGProps } from "react";
import { RobloxIcon, DiscordIcon, TiktokIcon, YoutubeIcon, FormIcon } from "./icons";
export interface LinkConfig {
name: string;
link: string;
desc: string;
icon: LucideIcon | string;
icon: ComponentType<SVGProps<SVGSVGElement>>;
}
export interface ProfileConfig {
@@ -24,25 +25,25 @@ export const config = {
name: "Roblox profile",
link: "https://www.roblox.com/users/4269713791/profile",
desc: "โปรไฟล์กุเองน้อง",
icon: "/icon/roblox.webp",
icon: RobloxIcon,
},
{
name: "Discord server",
link: "https://discord.com/invite/3cZQbvW6cF",
desc: "เข้าเซิร์ฟเวอร์ในดิส",
icon: "/icon/discord.webp",
icon: DiscordIcon,
},
{
name: "Tiktok",
link: "https://www.tiktok.com/@erika.bombay007",
desc: "กดติดตามใน Tiktok",
icon: "/icon/tiktok.webp",
icon: TiktokIcon,
},
{
name: "Youtube",
link: "https://www.youtube.com/@Erika%E0%B9%80%E0%B8%AD%E0%B8%A3%E0%B8%B4%E0%B8%81%E0%B9%89%E0%B8%B2/videos",
desc: "กดติดตามใน Youtube",
icon: "/icon/youtube.webp",
icon: YoutubeIcon,
}
] as LinkConfig[]
};
@@ -54,7 +55,7 @@ export const config_p2 = {
name: "Form",
link: "/form",
desc: "Custom Form ของช่อง erika",
icon: "/icon/form.webp",
icon: FormIcon,
}
] as LinkConfig[]
};
+1
View File
@@ -9,6 +9,7 @@ export async function getDiscordMemberCount(): Promise<number | null> {
{
headers: { Authorization: `Bot ${token}` },
next: { revalidate: 3600 },
signal: AbortSignal.timeout(5000),
}
);
if (!res.ok) return null;
+25 -1
View File
@@ -15,7 +15,21 @@ export interface FollowerInfo {
label: string;
}
const CACHE_KEY = "erika:follower_counts";
const CACHE_TTL = 3600; // 1 hour
export async function getFollowerCounts(): Promise<FollowerCounts> {
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
const cached = await redis.get(CACHE_KEY);
if (cached) {
return JSON.parse(cached);
}
} catch {
// Redis unavailable, fall through to fetch
}
const [youtube, roblox, discord, tiktok] = await Promise.all([
getYoutubeSubscriberCount(),
getRobloxFollowerCount(),
@@ -23,7 +37,17 @@ export async function getFollowerCounts(): Promise<FollowerCounts> {
getTiktokFollowerCount(),
]);
return { youtube, roblox, discord, tiktok };
const counts: FollowerCounts = { youtube, roblox, discord, tiktok };
try {
const { getRedisClient } = await import("@/lib/redis");
const redis = await getRedisClient();
await redis.set(CACHE_KEY, JSON.stringify(counts), { ex: CACHE_TTL });
} catch {
// Redis unavailable, skip caching
}
return counts;
}
const followerLabels: Record<keyof FollowerCounts, string> = {
+1 -1
View File
@@ -5,7 +5,7 @@ export async function getRobloxFollowerCount(): Promise<number | null> {
try {
const res = await fetch(
`https://friends.roblox.com/v1/users/${encodeURIComponent(userId)}/followers/count`,
{ next: { revalidate: 3600 } }
{ next: { revalidate: 3600 }, signal: AbortSignal.timeout(5000) }
);
if (!res.ok) return null;
+2 -1
View File
@@ -8,7 +8,8 @@ export async function getTiktokFollowerCount(): Promise<number | null> {
`https://api.scrapecreators.com/v1/tiktok/profile?user_id=${encodeURIComponent(userId)}`,
{
headers: { 'x-api-key': apiKey },
next: { revalidate: 86400 }
next: { revalidate: 86400 },
signal: AbortSignal.timeout(5000),
}
);
if (!res.ok) return null;
+1 -1
View File
@@ -6,7 +6,7 @@ export async function getYoutubeSubscriberCount(): Promise<number | null> {
try {
const res = await fetch(
`https://www.googleapis.com/youtube/v3/channels?part=statistics&id=${encodeURIComponent(channelId)}&key=${encodeURIComponent(apiKey)}`,
{ next: { revalidate: 3600 } }
{ next: { revalidate: 3600 }, signal: AbortSignal.timeout(5000) }
);
if (!res.ok) return null;
+37
View File
@@ -0,0 +1,37 @@
import type { ComponentType, SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement>;
export const RobloxIcon: ComponentType<IconProps> = (props) => (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M5.164 0 0 18.627 18.836 24 24 5.373ZM13.474 15.898l-5.063-1.33 1.37-5.188 5.063 1.33z" />
</svg>
);
export const DiscordIcon: ComponentType<IconProps> = (props) => (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
export const TiktokIcon: ComponentType<IconProps> = (props) => (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-2.88 2.5 2.89 2.89 0 0 1-2.89-2.89 2.89 2.89 0 0 1 2.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 0 0-.79-.05A6.34 6.34 0 0 0 3.15 15a6.34 6.34 0 0 0 6.34 6.34 6.34 6.34 0 0 0 6.34-6.34V9.11a8.16 8.16 0 0 0 4.76 1.52v-3.4c-.85 0-1.65-.2-2.37-.54h-.02z" />
</svg>
);
export const YoutubeIcon: ComponentType<IconProps> = (props) => (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
);
export const FormIcon: ComponentType<IconProps> = (props) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
+29 -3
View File
@@ -9,15 +9,41 @@ http {
error_log /dev/stderr warn;
access_log /dev/stdout;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml;
# Define rate limit zone: 10MB memory, 5 requests per second per IP
limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=5r/s;
server {
listen 80;
listen 8606;
# Bypass rate limit for static assets and Next.js internal files
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
# Cache static assets for 1 year (they have hashed filenames)
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /_next/ {
proxy_pass http://erika:3000;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -30,7 +56,7 @@ http {
limit_req zone=ratelimit burst=30 nodelay;
# Proxy to the Next.js app
proxy_pass http://erika:3000;
proxy_pass http://127.0.0.1:3000;
# Next.js often requires HTTP/1.1
proxy_http_version 1.1;