Skip to content

Commit bc51d22

Browse files
committed
feat: harden production readiness with error boundary, CI/CD, and parser cleanup
- Fix ThemeToggle React hooks warning with lazy initializer - Remove parser code duplication via shared analyzeZip helper - Add 500MB file size validation with i18n error messages - Add Error Boundary component wrapping main content - Add GitHub Actions CI/CD workflow Co-Authored-By: heznpc <heznpc@gmail.com>
1 parent dcce719 commit bc51d22

8 files changed

Lines changed: 164 additions & 75 deletions

File tree

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
node-version: [20]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Use Node.js ${{ matrix.node-version }}
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: ${{ matrix.node-version }}
24+
cache: npm
25+
26+
- name: Install dependencies
27+
run: npm ci
28+
29+
- name: Lint
30+
run: npm run lint
31+
32+
- name: Run tests
33+
run: npm test

src/app/page.tsx

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
"use client";
22

3-
import { useState, useEffect } from "react";
3+
import { useState } from "react";
44
import { I18nProvider, useI18n } from "@/lib/i18n";
55
import type { FullData } from "@/lib/types";
66
import Guide from "@/components/Guide";
77
import FileUpload from "@/components/FileUpload";
88
import Dashboard from "@/components/Dashboard";
9+
import ErrorBoundary from "@/components/ErrorBoundary";
910

1011
function ThemeToggle() {
11-
const [theme, setTheme] = useState("dark");
12-
13-
useEffect(() => {
14-
setTheme(document.documentElement.getAttribute("data-theme") || "dark");
15-
}, []);
12+
const [theme, setTheme] = useState(() =>
13+
typeof document !== "undefined"
14+
? document.documentElement.getAttribute("data-theme") || "dark"
15+
: "dark"
16+
);
1617

1718
const toggle = () => {
1819
const next = theme === "dark" ? "light" : "dark";
@@ -138,19 +139,21 @@ function AppContent() {
138139
<BackgroundOrbs />
139140
<Header />
140141
<main className="relative z-10 max-w-5xl mx-auto px-6 pb-12">
141-
{!result ? (
142-
<>
143-
<Hero />
144-
<Guide />
145-
<FileUpload onResult={setResult} />
146-
</>
147-
) : (
148-
<Dashboard
149-
data={result.analysis}
150-
insights={result.insights}
151-
onReset={() => setResult(null)}
152-
/>
153-
)}
142+
<ErrorBoundary>
143+
{!result ? (
144+
<>
145+
<Hero />
146+
<Guide />
147+
<FileUpload onResult={setResult} />
148+
</>
149+
) : (
150+
<Dashboard
151+
data={result.analysis}
152+
insights={result.insights}
153+
onReset={() => setResult(null)}
154+
/>
155+
)}
156+
</ErrorBoundary>
154157
</main>
155158
<Footer />
156159
</div>

src/components/ErrorBoundary.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"use client";
2+
3+
import React from "react";
4+
5+
interface Props {
6+
children: React.ReactNode;
7+
}
8+
9+
interface State {
10+
hasError: boolean;
11+
error: Error | null;
12+
}
13+
14+
export default class ErrorBoundary extends React.Component<Props, State> {
15+
constructor(props: Props) {
16+
super(props);
17+
this.state = { hasError: false, error: null };
18+
}
19+
20+
static getDerivedStateFromError(error: Error): State {
21+
return { hasError: true, error };
22+
}
23+
24+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
25+
console.error("[ErrorBoundary]", error, errorInfo);
26+
}
27+
28+
private handleReset = () => {
29+
this.setState({ hasError: false, error: null });
30+
};
31+
32+
render() {
33+
if (this.state.hasError) {
34+
return (
35+
<div className="flex flex-col items-center justify-center py-24 px-6 text-center">
36+
<div className="glass rounded-3xl p-10 max-w-md w-full space-y-6">
37+
<div className="w-16 h-16 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
38+
<svg
39+
className="w-8 h-8 text-red-500"
40+
fill="none"
41+
viewBox="0 0 24 24"
42+
stroke="currentColor"
43+
>
44+
<path
45+
strokeLinecap="round"
46+
strokeLinejoin="round"
47+
strokeWidth={2}
48+
d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
49+
/>
50+
</svg>
51+
</div>
52+
<h2 className="text-xl font-bold text-zinc-900 dark:text-zinc-100">
53+
Something went wrong
54+
</h2>
55+
<p className="text-sm text-zinc-500 dark:text-zinc-400">
56+
An unexpected error occurred. Please try again.
57+
</p>
58+
{process.env.NODE_ENV === "development" && this.state.error && (
59+
<pre className="text-left text-xs bg-zinc-100 dark:bg-zinc-800 rounded-xl p-4 overflow-auto max-h-40 text-red-600 dark:text-red-400">
60+
{this.state.error.message}
61+
</pre>
62+
)}
63+
<button
64+
onClick={this.handleReset}
65+
className="px-6 py-3 rounded-2xl bg-gradient-to-r from-violet-600 to-pink-500 text-white font-bold hover:scale-105 transition-transform"
66+
>
67+
Try Again
68+
</button>
69+
</div>
70+
</div>
71+
);
72+
}
73+
74+
return this.props.children;
75+
}
76+
}

src/components/FileUpload.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useI18n } from "@/lib/i18n";
55
import { parseFileFull } from "@/lib/parser";
66
import type { FullData } from "@/lib/types";
77

8+
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
9+
810
interface Props {
911
onResult: (result: FullData) => void;
1012
}
@@ -19,6 +21,12 @@ export default function FileUpload({ onResult }: Props) {
1921
const handleFile = useCallback(
2022
async (file: File) => {
2123
setError(null);
24+
25+
if (file.size > MAX_FILE_SIZE) {
26+
setError(t("upload.error.FILE_TOO_LARGE"));
27+
return;
28+
}
29+
2230
setProcessing(true);
2331
try {
2432
const result = await parseFileFull(file);

src/components/StoryCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export default function StoryCard({
202202
// Highlights
203203
if (result.highlights.length > 0) {
204204
const hlY = 1380;
205-
let hlX = W / 2;
205+
const hlX = W / 2;
206206
ctx.textAlign = "center";
207207
const tags = result.highlights.slice(0, 4).map((k) => t(k));
208208
ctx.fillStyle = "rgba(255,255,255,0.4)";

src/lib/parser.ts

Lines changed: 22 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -103,19 +103,9 @@ async function findAccounts(
103103
return accounts;
104104
}
105105

106-
// ── Main entry ──
107-
108-
export async function parseInstagramZip(
109-
file: File
110-
): Promise<AnalysisResult> {
111-
const zip = await JSZip.loadAsync(file);
112-
113-
const paths = Object.keys(zip.files);
114-
const isInstagram = paths.some(
115-
(p) => p.includes("followers") || p.includes("following")
116-
);
117-
if (!isInstagram) throw new Error("INVALID_ZIP");
106+
// ── Shared analysis helper ──
118107

108+
async function analyzeZip(zip: JSZip): Promise<AnalysisResult> {
119109
const [
120110
followers,
121111
following,
@@ -151,59 +141,36 @@ export async function parseInstagramZip(
151141
};
152142
}
153143

154-
export async function parseFileFull(file: File): Promise<FullData> {
155-
if (!file.name.endsWith(".zip")) throw new Error("UNSUPPORTED_FORMAT");
156-
157-
const zip = await JSZip.loadAsync(file);
144+
// ── Validate ZIP contains Instagram data ──
158145

146+
function validateInstagramZip(zip: JSZip): void {
159147
const paths = Object.keys(zip.files);
160148
const isInstagram = paths.some(
161149
(p) => p.includes("followers") || p.includes("following")
162150
);
163151
if (!isInstagram) throw new Error("INVALID_ZIP");
152+
}
164153

165-
const [analysis, insights] = await Promise.all([
166-
parseInstagramZipFromLoaded(zip),
167-
parseInsights(zip),
168-
]);
154+
// ── Main entry ──
169155

170-
return { analysis, insights };
156+
export async function parseInstagramZip(
157+
file: File
158+
): Promise<AnalysisResult> {
159+
const zip = await JSZip.loadAsync(file);
160+
validateInstagramZip(zip);
161+
return analyzeZip(zip);
171162
}
172163

173-
async function parseInstagramZipFromLoaded(
174-
zip: JSZip
175-
): Promise<AnalysisResult> {
176-
const [
177-
followers,
178-
following,
179-
pending,
180-
unfollowed,
181-
closeFriends,
182-
blocked,
183-
restricted,
184-
] = await Promise.all([
185-
findAccounts(zip, "followers"),
186-
findAccounts(zip, "following"),
187-
findAccounts(zip, "pending_follow"),
188-
findAccounts(zip, "unfollowed"),
189-
findAccounts(zip, "close_friends"),
190-
findAccounts(zip, "blocked"),
191-
findAccounts(zip, "restricted"),
192-
]);
164+
export async function parseFileFull(file: File): Promise<FullData> {
165+
if (!file.name.endsWith(".zip")) throw new Error("UNSUPPORTED_FORMAT");
193166

194-
const followerSet = new Set(followers.map((a) => a.username));
195-
const followingSet = new Set(following.map((a) => a.username));
167+
const zip = await JSZip.loadAsync(file);
168+
validateInstagramZip(zip);
196169

197-
return {
198-
followers,
199-
following,
200-
pendingRequests: pending,
201-
recentlyUnfollowed: unfollowed,
202-
closeFriends,
203-
blockedAccounts: blocked,
204-
restrictedAccounts: restricted,
205-
nonMutual: following.filter((a) => !followerSet.has(a.username)),
206-
fansOnly: followers.filter((a) => !followingSet.has(a.username)),
207-
mutual: following.filter((a) => followerSet.has(a.username)),
208-
};
170+
const [analysis, insights] = await Promise.all([
171+
analyzeZip(zip),
172+
parseInsights(zip),
173+
]);
174+
175+
return { analysis, insights };
209176
}

src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"error": {
4848
"INVALID_ZIP": "This doesn't look like an Instagram data export. Make sure you downloaded the ZIP from Instagram.",
4949
"UNSUPPORTED_FORMAT": "Please upload a .zip file from Instagram's data export.",
50+
"FILE_TOO_LARGE": "File is too large. Maximum allowed size is 500 MB.",
5051
"default": "Something went wrong. Please try again with a valid Instagram data export."
5152
}
5253
},

src/locales/ko.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"error": {
4848
"INVALID_ZIP": "인스타그램 데이터 내보내기 파일이 아닌 것 같습니다. 인스타그램에서 다운로드한 ZIP 파일인지 확인해주세요.",
4949
"UNSUPPORTED_FORMAT": "인스타그램 데이터 내보내기에서 받은 .zip 파일을 올려주세요.",
50+
"FILE_TOO_LARGE": "파일이 너무 큽니다. 최대 허용 크기는 500 MB입니다.",
5051
"default": "문제가 발생했습니다. 유효한 인스타그램 데이터 파일로 다시 시도해주세요."
5152
}
5253
},

0 commit comments

Comments
 (0)