haaaaus commited on
Commit
78dd59e
·
verified ·
1 Parent(s): 98f0ca3

Upload 17 files

Browse files
Files changed (5) hide show
  1. Dockerfile +34 -34
  2. package.json +1 -1
  3. public/reader.html +4 -64
  4. public/styles.css +27 -0
  5. server.js +90 -23
Dockerfile CHANGED
@@ -1,34 +1,34 @@
1
- # Use Node.js LTS image
2
- FROM node:20-slim
3
-
4
- # Set working directory
5
- WORKDIR /app
6
-
7
- # Set environment variables
8
- ENV NODE_ENV=production \
9
- PORT=7860
10
-
11
- # Copy package files first for better caching
12
- COPY package*.json ./
13
-
14
- # Install dependencies
15
- RUN npm ci --omit=dev
16
-
17
- # Copy application source
18
- COPY . .
19
-
20
- # Create data directory and set permissions
21
- RUN mkdir -p data && chown -R node:node /app
22
-
23
- # Switch to non-root user
24
- USER node
25
-
26
- # Expose the port (HF Spaces uses 7860 by default)
27
- EXPOSE 7860
28
-
29
- # Health check
30
- HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
31
- CMD node -e "require('http').get('http://localhost:7860/api/stories', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
32
-
33
- # Start the application
34
- CMD ["node", "server.js"]
 
1
+ # Use Node.js LTS image
2
+ FROM node:20-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Set environment variables
8
+ ENV NODE_ENV=production \
9
+ PORT=7860
10
+
11
+ # Copy package files first for better caching
12
+ COPY package*.json ./
13
+
14
+ # Install dependencies
15
+ RUN npm ci --omit=dev
16
+
17
+ # Copy application source
18
+ COPY . .
19
+
20
+ # Create data directory and set permissions
21
+ RUN mkdir -p data && chown -R node:node /app
22
+
23
+ # Switch to non-root user
24
+ USER node
25
+
26
+ # Expose the port (HF Spaces uses 7860 by default)
27
+ EXPOSE 7860
28
+
29
+ # Health check
30
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
31
+ CMD node -e "require('http').get('http://localhost:7860/api/stories', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
32
+
33
+ # Start the application
34
+ CMD ["node", "server.js"]
package.json CHANGED
@@ -10,4 +10,4 @@
10
  "dependencies": {
11
  "express": "^4.18.2"
12
  }
13
- }
 
10
  "dependencies": {
11
  "express": "^4.18.2"
12
  }
13
+ }
public/reader.html CHANGED
@@ -88,38 +88,22 @@
88
  setupChapterSelector();
89
  });
90
 
91
- // Image chunking settings
92
- const MAX_IMAGE_HEIGHT = 2000; // Chiều cao tối đa trước khi cắt
93
- const CHUNK_HEIGHT = 1500; // Chiều cao mỗi chunk
94
-
95
  /**
96
- * Display image immediately - no waiting
97
- * @param {string} src - Image source URL
98
- * @param {HTMLElement} wrapper - Wrapper element to render into
99
- * @param {number} pageNum - Page number for alt text
100
  */
101
- function loadAndChunkImage(src, wrapper, pageNum) {
102
- // Create img element and display immediately
103
  const imgEl = document.createElement('img');
104
  imgEl.src = src;
105
  imgEl.alt = `Trang ${pageNum}`;
106
  imgEl.className = 'page-image';
107
  imgEl.loading = 'lazy';
108
 
109
- // Clear placeholder and show image immediately
110
  wrapper.innerHTML = '';
111
  wrapper.appendChild(imgEl);
112
 
113
- // Fade-in when image loads
114
  imgEl.onload = () => {
115
  imgEl.classList.add('loaded');
116
  wrapper.classList.add('loaded');
117
-
118
- // Check if image is too tall and needs chunking
119
- const { naturalWidth: width, naturalHeight: height } = imgEl;
120
- if (height > MAX_IMAGE_HEIGHT) {
121
- chunkLongImage(imgEl, wrapper, pageNum, width, height);
122
- }
123
  };
124
 
125
  imgEl.onerror = () => {
@@ -132,56 +116,12 @@
132
  wrapper.classList.add('loaded');
133
  };
134
 
135
- // If cached, trigger loaded immediately
136
  if (imgEl.complete && imgEl.naturalHeight > 0) {
137
  imgEl.classList.add('loaded');
138
  wrapper.classList.add('loaded');
139
  }
140
  }
141
 
142
- /**
143
- * Chunk a long image into smaller pieces (runs after image is loaded)
144
- */
145
- function chunkLongImage(img, wrapper, pageNum, width, height) {
146
- try {
147
- const numChunks = Math.ceil(height / CHUNK_HEIGHT);
148
- wrapper.innerHTML = ''; // Clear original image
149
-
150
- for (let i = 0; i < numChunks; i++) {
151
- const chunkStart = i * CHUNK_HEIGHT;
152
- const chunkEnd = Math.min((i + 1) * CHUNK_HEIGHT, height);
153
- const actualChunkHeight = chunkEnd - chunkStart;
154
-
155
- // Create canvas for this chunk
156
- const canvas = document.createElement('canvas');
157
- canvas.width = width;
158
- canvas.height = actualChunkHeight;
159
-
160
- const ctx = canvas.getContext('2d');
161
- ctx.drawImage(
162
- img,
163
- 0, chunkStart, // Source x, y
164
- width, actualChunkHeight, // Source width, height
165
- 0, 0, // Destination x, y
166
- width, actualChunkHeight // Destination width, height
167
- );
168
-
169
- // Convert canvas to image element
170
- const chunkImg = document.createElement('img');
171
- chunkImg.src = canvas.toDataURL('image/webp', 0.9);
172
- chunkImg.alt = `Trang ${pageNum} - Phần ${i + 1}/${numChunks}`;
173
- chunkImg.className = 'page-image page-chunk loaded';
174
-
175
- wrapper.appendChild(chunkImg);
176
- }
177
-
178
- wrapper.classList.add('chunked-page');
179
- } catch (error) {
180
- console.error('Failed to chunk image:', error);
181
- // Keep original image if chunking fails
182
- }
183
- }
184
-
185
  async function loadImages() {
186
  const container = document.getElementById('readerContainer');
187
  const chapterInfo = document.getElementById('chapterInfo');
@@ -251,8 +191,8 @@
251
  `;
252
  container.appendChild(wrapper);
253
 
254
- // Load and potentially chunk the image
255
- loadAndChunkImage(imgSrc, wrapper, index + 1);
256
  }
257
 
258
  // Add end of chapter navigation
 
88
  setupChapterSelector();
89
  });
90
 
 
 
 
 
91
  /**
92
+ * Load image directly
 
 
 
93
  */
94
+ function loadImage(src, wrapper, pageNum) {
 
95
  const imgEl = document.createElement('img');
96
  imgEl.src = src;
97
  imgEl.alt = `Trang ${pageNum}`;
98
  imgEl.className = 'page-image';
99
  imgEl.loading = 'lazy';
100
 
 
101
  wrapper.innerHTML = '';
102
  wrapper.appendChild(imgEl);
103
 
 
104
  imgEl.onload = () => {
105
  imgEl.classList.add('loaded');
106
  wrapper.classList.add('loaded');
 
 
 
 
 
 
107
  };
108
 
109
  imgEl.onerror = () => {
 
116
  wrapper.classList.add('loaded');
117
  };
118
 
 
119
  if (imgEl.complete && imgEl.naturalHeight > 0) {
120
  imgEl.classList.add('loaded');
121
  wrapper.classList.add('loaded');
122
  }
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  async function loadImages() {
126
  const container = document.getElementById('readerContainer');
127
  const chapterInfo = document.getElementById('chapterInfo');
 
191
  `;
192
  container.appendChild(wrapper);
193
 
194
+ // Load the image
195
+ loadImage(imgSrc, wrapper, index + 1);
196
  }
197
 
198
  // Add end of chapter navigation
public/styles.css CHANGED
@@ -846,6 +846,33 @@ img {
846
  }
847
  }
848
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  .empty-state,
850
  .error-state {
851
  grid-column: 1 / -1;
 
846
  }
847
  }
848
 
849
+ /* Chunk Loading Styles */
850
+ .spinner-small {
851
+ width: 24px;
852
+ height: 24px;
853
+ border: 2px solid var(--border-color);
854
+ border-top-color: var(--accent-primary);
855
+ border-radius: 50%;
856
+ animation: spin 1s linear infinite;
857
+ }
858
+
859
+ .chunk-wrapper {
860
+ min-height: 100px;
861
+ display: flex;
862
+ align-items: center;
863
+ justify-content: center;
864
+ background: var(--bg-secondary);
865
+ }
866
+
867
+ .chunk-loading {
868
+ padding: var(--spacing-lg);
869
+ }
870
+
871
+ .error-state.small {
872
+ padding: var(--spacing-md);
873
+ font-size: 0.875rem;
874
+ }
875
+
876
  .empty-state,
877
  .error-state {
878
  grid-column: 1 / -1;
server.js CHANGED
@@ -15,38 +15,95 @@ const DATA_DIR = path.join(__dirname, 'data');
15
  app.use(express.static(path.join(__dirname, 'public')));
16
  app.use('/data', express.static(DATA_DIR));
17
 
 
18
  // Image Proxy Endpoint
 
 
19
  app.get('/api/proxy/*', (req, res) => {
20
  const assetPath = req.params[0];
21
- if (!assetPath) return res.status(400).send('Invalid path');
22
 
23
- const fetchUrl = (url) => {
24
- https.get(url, (proxyRes) => {
25
- // Handle Redirects
26
- if (proxyRes.statusCode >= 300 && proxyRes.statusCode < 400 && proxyRes.headers.location) {
27
- return fetchUrl(proxyRes.headers.location);
 
 
 
 
 
 
 
 
 
 
28
  }
 
 
29
 
30
- if (proxyRes.statusCode !== 200) {
31
- return res.status(proxyRes.statusCode).send('Error fetching image');
 
 
 
 
32
  }
33
 
34
- // Forward content-type
35
- if (proxyRes.headers['content-type']) {
36
- res.setHeader('Content-Type', proxyRes.headers['content-type']);
 
 
 
 
 
 
 
37
  }
38
 
39
- // Cache control
 
 
 
 
 
 
 
 
40
  res.setHeader('Cache-Control', 'public, max-age=3600');
 
41
 
 
42
  proxyRes.pipe(res);
43
- }).on('error', (err) => {
44
- console.error('Proxy error:', err);
45
- if (!res.headersSent) res.status(500).send('Proxy error');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  });
47
  };
48
 
49
- fetchUrl(`${getHFAssetBase()}/${assetPath}`);
50
  });
51
 
52
  // Helper: Get local stories
@@ -265,25 +322,35 @@ app.get('/api/stories/:story/chapters/:chapter/images', (req, res) => {
265
  }
266
  });
267
 
268
- // Fallback to index.html for SPA routes
269
- app.get('*', (req, res) => {
270
- res.sendFile(path.join(__dirname, 'public', 'index.html'));
271
- });
272
-
273
  // API: Get cache status and manual refresh
274
  app.get('/api/hf-status', (req, res) => {
275
  res.json(getHFCacheStatus());
276
  });
277
 
 
 
 
 
 
 
 
 
 
 
278
  app.post('/api/refresh', async (req, res) => {
279
  try {
280
  const result = await forceRefreshHFData();
281
- res.json(result);
282
  } catch (error) {
283
- res.status(500).json({ error: 'Failed to refresh' });
284
  }
285
  });
286
 
 
 
 
 
 
287
  // Start server and fetch data
288
  app.listen(PORT, async () => {
289
  console.log(`🚀 Web Truyện is running at http://localhost:${PORT}`);
 
15
  app.use(express.static(path.join(__dirname, 'public')));
16
  app.use('/data', express.static(DATA_DIR));
17
 
18
+ // ============================================
19
  // Image Proxy Endpoint
20
+ // Proxy images from Hugging Face with proper URL encoding
21
+ // ============================================
22
  app.get('/api/proxy/*', (req, res) => {
23
  const assetPath = req.params[0];
 
24
 
25
+ if (!assetPath) {
26
+ return res.status(400).json({ error: 'Missing asset path' });
27
+ }
28
+
29
+ const MAX_REDIRECTS = 5;
30
+ const TIMEOUT_MS = 30000; // 30 seconds timeout
31
+
32
+ // Build properly encoded URL
33
+ const targetUrl = encodeURI(`${getHFAssetBase()}/${assetPath}`);
34
+
35
+ const fetchWithRedirects = (url, redirectCount = 0) => {
36
+ if (redirectCount > MAX_REDIRECTS) {
37
+ console.error('[Proxy] Too many redirects');
38
+ if (!res.headersSent) {
39
+ return res.status(502).json({ error: 'Too many redirects' });
40
  }
41
+ return;
42
+ }
43
 
44
+ const request = https.get(url, { timeout: TIMEOUT_MS }, (proxyRes) => {
45
+ const { statusCode, headers } = proxyRes;
46
+
47
+ // Handle redirects (301, 302, 303, 307, 308)
48
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
49
+ return fetchWithRedirects(headers.location, redirectCount + 1);
50
  }
51
 
52
+ // Handle non-success status codes
53
+ if (statusCode !== 200) {
54
+ console.error(`[Proxy] Upstream error: ${statusCode} for ${url.substring(0, 80)}`);
55
+ if (!res.headersSent) {
56
+ return res.status(statusCode).json({
57
+ error: 'Failed to fetch resource',
58
+ upstream_status: statusCode
59
+ });
60
+ }
61
+ return;
62
  }
63
 
64
+ // Forward response headers
65
+ if (headers['content-type']) {
66
+ res.setHeader('Content-Type', headers['content-type']);
67
+ }
68
+ if (headers['content-length']) {
69
+ res.setHeader('Content-Length', headers['content-length']);
70
+ }
71
+
72
+ // Set caching headers (1 hour for images)
73
  res.setHeader('Cache-Control', 'public, max-age=3600');
74
+ res.setHeader('X-Proxy-Source', 'huggingface');
75
 
76
+ // Stream response to client
77
  proxyRes.pipe(res);
78
+
79
+ // Handle stream errors
80
+ proxyRes.on('error', (err) => {
81
+ console.error('[Proxy] Stream error:', err.message);
82
+ if (!res.headersSent) {
83
+ res.status(500).json({ error: 'Stream error' });
84
+ }
85
+ });
86
+ });
87
+
88
+ // Handle request errors
89
+ request.on('error', (err) => {
90
+ console.error('[Proxy] Request error:', err.message);
91
+ if (!res.headersSent) {
92
+ res.status(502).json({ error: 'Failed to connect to upstream' });
93
+ }
94
+ });
95
+
96
+ // Handle timeout
97
+ request.on('timeout', () => {
98
+ console.error('[Proxy] Request timeout');
99
+ request.destroy();
100
+ if (!res.headersSent) {
101
+ res.status(504).json({ error: 'Upstream timeout' });
102
+ }
103
  });
104
  };
105
 
106
+ fetchWithRedirects(targetUrl);
107
  });
108
 
109
  // Helper: Get local stories
 
322
  }
323
  });
324
 
 
 
 
 
 
325
  // API: Get cache status and manual refresh
326
  app.get('/api/hf-status', (req, res) => {
327
  res.json(getHFCacheStatus());
328
  });
329
 
330
+ // Refresh HF data - supports both GET and POST
331
+ app.get('/api/refresh', async (req, res) => {
332
+ try {
333
+ const result = await forceRefreshHFData();
334
+ res.json({ success: true, ...result });
335
+ } catch (error) {
336
+ res.status(500).json({ success: false, error: 'Failed to refresh' });
337
+ }
338
+ });
339
+
340
  app.post('/api/refresh', async (req, res) => {
341
  try {
342
  const result = await forceRefreshHFData();
343
+ res.json({ success: true, ...result });
344
  } catch (error) {
345
+ res.status(500).json({ success: false, error: 'Failed to refresh' });
346
  }
347
  });
348
 
349
+ // Fallback to index.html for SPA routes - MUST BE LAST
350
+ app.get('*', (req, res) => {
351
+ res.sendFile(path.join(__dirname, 'public', 'index.html'));
352
+ });
353
+
354
  // Start server and fetch data
355
  app.listen(PORT, async () => {
356
  console.log(`🚀 Web Truyện is running at http://localhost:${PORT}`);