feat: add Redis caching for follower counts, implement fetch timeouts, enable Nginx gzip compression, and refactor icon management.
This commit is contained in:
+2
-14
@@ -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
@@ -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
@@ -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[]
|
||||
};
|
||||
|
||||
@@ -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
@@ -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> = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user