fantos commited on
Commit
cf7ba72
·
verified ·
1 Parent(s): 6148071

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +802 -324
app.py CHANGED
@@ -1,346 +1,824 @@
1
- import spaces
2
- import random
3
- import torch
4
- import cv2
5
- import gradio as gr
6
- import numpy as np
7
- from huggingface_hub import snapshot_download
8
- from transformers import CLIPVisionModelWithProjection, CLIPImageProcessor
9
- from diffusers.utils import load_image
10
- from kolors.pipelines.pipeline_controlnet_xl_kolors_img2img import StableDiffusionXLControlNetImg2ImgPipeline
11
- from kolors.models.modeling_chatglm import ChatGLMModel
12
- from kolors.models.tokenization_chatglm import ChatGLMTokenizer
13
- from kolors.models.controlnet import ControlNetModel
14
- from diffusers import AutoencoderKL
15
- from kolors.models.unet_2d_condition import UNet2DConditionModel
16
- from diffusers import EulerDiscreteScheduler
17
- from PIL import Image
18
- from annotator.midas import MidasDetector
19
- from annotator.dwpose import DWposeDetector
20
- from annotator.util import resize_image, HWC3
21
 
22
- device = "cuda"
23
- ckpt_dir = snapshot_download(repo_id="Kwai-Kolors/Kolors")
24
- ckpt_dir_depth = snapshot_download(repo_id="Kwai-Kolors/Kolors-ControlNet-Depth")
25
- ckpt_dir_canny = snapshot_download(repo_id="Kwai-Kolors/Kolors-ControlNet-Canny")
26
- ckpt_dir_pose = snapshot_download(repo_id="Kwai-Kolors/Kolors-ControlNet-Pose")
27
 
28
- text_encoder = ChatGLMModel.from_pretrained(f'{ckpt_dir}/text_encoder', torch_dtype=torch.float16).half().to(device)
29
- tokenizer = ChatGLMTokenizer.from_pretrained(f'{ckpt_dir}/text_encoder')
30
- vae = AutoencoderKL.from_pretrained(f"{ckpt_dir}/vae", revision=None).half().to(device)
31
- scheduler = EulerDiscreteScheduler.from_pretrained(f"{ckpt_dir}/scheduler")
32
- unet = UNet2DConditionModel.from_pretrained(f"{ckpt_dir}/unet", revision=None).half().to(device)
33
- controlnet_depth = ControlNetModel.from_pretrained(f"{ckpt_dir_depth}", revision=None).half().to(device)
34
- controlnet_canny = ControlNetModel.from_pretrained(f"{ckpt_dir_canny}", revision=None).half().to(device)
35
- controlnet_pose = ControlNetModel.from_pretrained(f"{ckpt_dir_pose}", revision=None).half().to(device)
36
 
37
- pipe_depth = StableDiffusionXLControlNetImg2ImgPipeline(
38
- vae=vae,
39
- controlnet=controlnet_depth,
40
- text_encoder=text_encoder,
41
- tokenizer=tokenizer,
42
- unet=unet,
43
- scheduler=scheduler,
44
- force_zeros_for_empty_prompt=False
45
- )
46
 
47
- pipe_canny = StableDiffusionXLControlNetImg2ImgPipeline(
48
- vae=vae,
49
- controlnet=controlnet_canny,
50
- text_encoder=text_encoder,
51
- tokenizer=tokenizer,
52
- unet=unet,
53
- scheduler=scheduler,
54
- force_zeros_for_empty_prompt=False
55
- )
56
 
57
- pipe_pose = StableDiffusionXLControlNetImg2ImgPipeline(
58
- vae=vae,
59
- controlnet=controlnet_pose,
60
- text_encoder=text_encoder,
61
- tokenizer=tokenizer,
62
- unet=unet,
63
- scheduler=scheduler,
64
- force_zeros_for_empty_prompt=False
65
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- @spaces.GPU
68
- def process_canny_condition(image, canny_threods=[100,200]):
69
- np_image = image.copy()
70
- np_image = cv2.Canny(np_image, canny_threods[0], canny_threods[1])
71
- np_image = np_image[:, :, None]
72
- np_image = np.concatenate([np_image, np_image, np_image], axis=2)
73
- np_image = HWC3(np_image)
74
- return Image.fromarray(np_image)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
- model_midas = MidasDetector()
77
- @spaces.GPU
78
- def process_depth_condition_midas(img, res = 1024):
79
- h,w,_ = img.shape
80
- img = resize_image(HWC3(img), res)
81
- result = HWC3(model_midas(img))
82
- result = cv2.resize(result, (w,h))
83
- return Image.fromarray(result)
84
 
85
- model_dwpose = DWposeDetector()
86
- @spaces.GPU
87
- def process_dwpose_condition(image, res=1024):
88
- h,w,_ = image.shape
89
- img = resize_image(HWC3(image), res)
90
- out_res, out_img = model_dwpose(image)
91
- result = HWC3(out_img)
92
- result = cv2.resize(result, (w,h))
93
- return Image.fromarray(result)
94
 
95
- MAX_SEED = np.iinfo(np.int32).max
96
- MAX_IMAGE_SIZE = 1024
 
 
 
 
 
 
97
 
98
- @spaces.GPU
99
- def infer_depth(prompt,
100
- image = None,
101
- negative_prompt = "NSFW, facial shadow, low resolution, JPEG artifacts, blurry, poor quality, blackface, neon lights.",
102
- seed = 397886929,
103
- randomize_seed = False,
104
- guidance_scale = 6.0,
105
- num_inference_steps = 50,
106
- controlnet_conditioning_scale = 0.7,
107
- control_guidance_end = 0.9,
108
- strength = 1.0
109
- ):
110
- if randomize_seed:
111
- seed = random.randint(0, MAX_SEED)
112
- generator = torch.Generator().manual_seed(seed)
113
- init_image = resize_image(image, MAX_IMAGE_SIZE)
114
- pipe = pipe_depth.to("cuda")
115
- condi_img = process_depth_condition_midas(np.array(init_image), MAX_IMAGE_SIZE)
116
- image = pipe(
117
- prompt=prompt,
118
- image=init_image,
119
- controlnet_conditioning_scale=controlnet_conditioning_scale,
120
- control_guidance_end=control_guidance_end,
121
- strength=strength,
122
- control_image=condi_img,
123
- negative_prompt=negative_prompt,
124
- num_inference_steps=num_inference_steps,
125
- guidance_scale=guidance_scale,
126
- num_images_per_prompt=1,
127
- generator=generator,
128
- ).images[0]
129
- return [condi_img, image], seed
130
 
131
- @spaces.GPU
132
- def infer_canny(prompt,
133
- image = None,
134
- negative_prompt = "NSFW, facial shadow, low resolution, JPEG artifacts, blurry, poor quality, blackface, neon lights.",
135
- seed = 397886929,
136
- randomize_seed = False,
137
- guidance_scale = 6.0,
138
- num_inference_steps = 50,
139
- controlnet_conditioning_scale = 0.7,
140
- control_guidance_end = 0.9,
141
- strength = 1.0
142
- ):
143
- if randomize_seed:
144
- seed = random.randint(0, MAX_SEED)
145
- generator = torch.Generator().manual_seed(seed)
146
- init_image = resize_image(image, MAX_IMAGE_SIZE)
147
- pipe = pipe_canny.to("cuda")
148
- condi_img = process_canny_condition(np.array(init_image))
149
- image = pipe(
150
- prompt=prompt,
151
- image=init_image,
152
- controlnet_conditioning_scale=controlnet_conditioning_scale,
153
- control_guidance_end=control_guidance_end,
154
- strength=strength,
155
- control_image=condi_img,
156
- negative_prompt=negative_prompt,
157
- num_inference_steps=num_inference_steps,
158
- guidance_scale=guidance_scale,
159
- num_images_per_prompt=1,
160
- generator=generator,
161
- ).images[0]
162
- return [condi_img, image], seed
163
 
164
- @spaces.GPU
165
- def infer_pose(prompt,
166
- image = None,
167
- negative_prompt = "NSFW, facial shadow, low resolution, JPEG artifacts, blurry, poor quality, blackface, neon lights.",
168
- seed = 66,
169
- randomize_seed = False,
170
- guidance_scale = 6.0,
171
- num_inference_steps = 50,
172
- controlnet_conditioning_scale = 0.7,
173
- control_guidance_end = 0.9,
174
- strength = 1.0
175
- ):
176
- if randomize_seed:
177
- seed = random.randint(0, MAX_SEED)
178
- generator = torch.Generator().manual_seed(seed)
179
- init_image = resize_image(image, MAX_IMAGE_SIZE)
180
- pipe = pipe_pose.to("cuda")
181
- condi_img = process_dwpose_condition(np.array(init_image), MAX_IMAGE_SIZE)
182
- image = pipe(
183
- prompt=prompt,
184
- image=init_image,
185
- controlnet_conditioning_scale=controlnet_conditioning_scale,
186
- control_guidance_end=control_guidance_end,
187
- strength=strength,
188
- control_image=condi_img,
189
- negative_prompt=negative_prompt,
190
- num_inference_steps=num_inference_steps,
191
- guidance_scale=guidance_scale,
192
- num_images_per_prompt=1,
193
- generator=generator,
194
- ).images[0]
195
- return [condi_img, image], seed
196
 
197
- canny_examples = [
198
- ["beautiful girl, high quality, very clear, vivid colors, ultra high resolution, best quality, 8k, high definition, 4K",
199
- "image/woman_1.png"],
200
- ["panorama, cute white puppy sitting in a cup, looking at camera, anime style, 3D rendering, octane render",
201
- "image/dog.png"]
202
- ]
 
 
 
 
203
 
204
- depth_examples = [
205
- ["Shinkai Makoto style, rich colors, woman in green shirt standing in field, beautiful landscape, clear and bright, dappled light and shadow, best quality, ultra-detailed, 8K quality",
206
- "image/woman_2.png"],
207
- ["colorful small bird, high quality, very clear, vivid colors, ultra high resolution, best quality, 8k, high definition, 4K",
208
- "image/bird.png"]
209
- ]
 
 
 
 
 
 
 
 
 
 
210
 
211
- pose_examples = [
212
- ["girl wearing purple puff sleeve dress with crown and white lace gloves, holding face with both hands, high quality, very clear, vivid colors, ultra high resolution, best quality, 8k, high definition, 4K",
213
- "image/woman_3.png"],
214
- ["woman wearing black sports jacket and white inner with necklace standing on street, background with red building and green trees, high quality, very clear, vivid colors, ultra high resolution, best quality, 8k, high definition, 4K",
215
- "image/woman_4.png"]
216
- ]
 
 
 
217
 
218
- css = """
219
- footer {
220
- visibility: hidden;
221
- }
222
- """
223
 
224
- def load_description(fp):
225
- with open(fp, 'r', encoding='utf-8') as f:
226
- content = f.read()
227
- return content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
 
229
- with gr.Blocks(theme="soft", css=css) as Kolors:
230
- with gr.Row():
231
- with gr.Column(elem_id="col-left"):
232
- with gr.Row():
233
- prompt = gr.Textbox(
234
- label="프롬프트",
235
- placeholder="프롬프트를 입력하세요",
236
- lines=2
237
- )
238
- with gr.Row():
239
- image = gr.Image(label="이미지", type="pil")
240
- with gr.Accordion("고급 설정", open=False):
241
- negative_prompt = gr.Textbox(
242
- label="네거티브 프롬프트",
243
- placeholder="네거티브 프롬프트를 입력하세요",
244
- visible=True,
245
- value="NSFW, facial shadow, low resolution, JPEG artifacts, blurry, poor quality, blackface, neon lights."
246
- )
247
- seed = gr.Slider(
248
- label="시드",
249
- minimum=0,
250
- maximum=MAX_SEED,
251
- step=1,
252
- value=0,
253
- )
254
- randomize_seed = gr.Checkbox(label="시드 무작위화", value=True)
255
- with gr.Row():
256
- guidance_scale = gr.Slider(
257
- label="가이던스 스케일",
258
- minimum=0.0,
259
- maximum=10.0,
260
- step=0.1,
261
- value=6.0,
262
- )
263
- num_inference_steps = gr.Slider(
264
- label="추론 단계 수",
265
- minimum=10,
266
- maximum=50,
267
- step=1,
268
- value=30,
269
- )
270
- with gr.Row():
271
- controlnet_conditioning_scale = gr.Slider(
272
- label="컨트롤넷 컨디셔닝 스케일",
273
- minimum=0.0,
274
- maximum=1.0,
275
- step=0.1,
276
- value=0.7,
277
- )
278
- control_guidance_end = gr.Slider(
279
- label="컨트롤 가이던스 종료",
280
- minimum=0.0,
281
- maximum=1.0,
282
- step=0.1,
283
- value=0.9,
284
- )
285
- with gr.Row():
286
- strength = gr.Slider(
287
- label="강도",
288
- minimum=0.0,
289
- maximum=1.0,
290
- step=0.1,
291
- value=1.0,
292
- )
293
- with gr.Row():
294
- canny_button = gr.Button("캐니", elem_id="button")
295
- depth_button = gr.Button("깊이", elem_id="button")
296
- pose_button = gr.Button("포즈", elem_id="button")
297
-
298
- with gr.Column(elem_id="col-right"):
299
- result = gr.Gallery(label="결과", show_label=False, columns=2)
300
- seed_used = gr.Number(label="사용된 시드")
301
-
302
- with gr.Row():
303
- gr.Examples(
304
- fn=infer_canny,
305
- examples=canny_examples,
306
- inputs=[prompt, image],
307
- outputs=[result, seed_used],
308
- label="Canny"
309
- )
310
- with gr.Row():
311
- gr.Examples(
312
- fn=infer_depth,
313
- examples=depth_examples,
314
- inputs=[prompt, image],
315
- outputs=[result, seed_used],
316
- label="Depth"
317
- )
318
-
319
- with gr.Row():
320
- gr.Examples(
321
- fn=infer_pose,
322
- examples=pose_examples,
323
- inputs=[prompt, image],
324
- outputs=[result, seed_used],
325
- label="Pose"
326
- )
327
 
328
- canny_button.click(
329
- fn=infer_canny,
330
- inputs=[prompt, image, negative_prompt, seed, randomize_seed, guidance_scale, num_inference_steps, controlnet_conditioning_scale, control_guidance_end, strength],
331
- outputs=[result, seed_used]
332
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
- depth_button.click(
335
- fn=infer_depth,
336
- inputs=[prompt, image, negative_prompt, seed, randomize_seed, guidance_scale, num_inference_steps, controlnet_conditioning_scale, control_guidance_end, strength],
337
- outputs=[result, seed_used]
338
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
- pose_button.click(
341
- fn=infer_pose,
342
- inputs=[prompt, image, negative_prompt, seed, randomize_seed, guidance_scale, num_inference_steps, controlnet_conditioning_scale, control_guidance_end, strength],
343
- outputs=[result, seed_used]
344
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
 
346
- Kolors.queue().launch(debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify
2
+ import os, re, json, sqlite3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ app = Flask(__name__)
 
 
 
 
5
 
6
+ # ────────────────────────── 1. CONFIGURATION ──────────────────────────
7
+ DB_FILE = "favorite_sites.json" # JSON file for backward compatibility
8
+ SQLITE_DB = "favorite_sites.db" # SQLite database for persistence
 
 
 
 
 
9
 
10
+ # Domains that commonly block iframes
11
+ BLOCKED_DOMAINS = [
12
+ "naver.com", "daum.net", "google.com",
13
+ "facebook.com", "instagram.com", "kakao.com",
14
+ "ycombinator.com"
15
+ ]
 
 
 
16
 
 
 
 
 
 
 
 
 
 
17
 
18
+ # ────────────────────────── 2. CURATED CATEGORIES ──────────────────────────
19
+ CATEGORIES = {
20
+ "Productivity": [
21
+ "https://huggingface.co/spaces/openfree/Chart-GPT",
22
+ "https://huggingface.co/spaces/ginipick/AI-BOOK",
23
+ "https://huggingface.co/spaces/VIDraft/Voice-Clone-Podcast",
24
+ "https://huggingface.co/spaces/ginipick/PDF-EXAM",
25
+ "https://huggingface.co/spaces/ginigen/perflexity-clone",
26
+ "https://huggingface.co/spaces/ginipick/IDEA-DESIGN",
27
+ "https://huggingface.co/spaces/ginipick/10m-marketing",
28
+
29
+ "https://huggingface.co/spaces/openfree/Live-Podcast",
30
+ "https://huggingface.co/spaces/openfree/AI-Podcast",
31
+ "https://huggingface.co/spaces/ginipick/QR-Canvas-plus",
32
+ "https://huggingface.co/spaces/openfree/Badge",
33
+ "https://huggingface.co/spaces/VIDraft/mouse-webgen",
34
+ "https://huggingface.co/spaces/openfree/Vibe-Game",
35
+ "https://huggingface.co/spaces/VIDraft/NH-Prediction",
36
+ "https://huggingface.co/spaces/ginipick/NH-Korea",
37
+ "https://huggingface.co/spaces/openfree/Naming",
38
+ "https://huggingface.co/spaces/ginipick/Change-Hair",
39
+ ],
40
+ "Multimodal": [
41
+ "https://huggingface.co/spaces/Heartsync/adult",
42
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored",
43
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video2",
44
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video",
45
+ "https://huggingface.co/spaces/Heartsync/WAN-VIDEO-AUDIO",
46
+ "https://huggingface.co/spaces/Heartsync/wan2-1-fast-security",
47
+ "https://huggingface.co/spaces/ginigen/Flux-VIDEO",
48
+ "https://huggingface.co/spaces/ginigen/3D-LLAMA-V1",
49
+ "https://huggingface.co/spaces/ginigen/Flux-VIDEO",
50
+ "https://huggingface.co/spaces/openfree/Multilingual-TTS",
51
+ "https://huggingface.co/spaces/VIDraft/ACE-Singer",
52
+ "https://huggingface.co/spaces/openfree/DreamO-video",
53
+ "https://huggingface.co/spaces/fantaxy/Sound-AI-SFX",
54
+ "https://huggingface.co/spaces/ginigen/SFX-Sound-magic",
55
+ "https://huggingface.co/spaces/ginigen/VoiceClone-TTS",
56
+ "https://huggingface.co/spaces/aiqcamp/ENGLISH-Speaking-Scoring",
57
+ "https://huggingface.co/spaces/fantaxy/Remove-Video-Background",
58
+ ],
59
+ "Professional": [
60
+ "https://huggingface.co/spaces/Heartsync/Novel-NSFW",
61
+ "https://huggingface.co/spaces/fantaxy/fantasy-novel",
62
+ "https://huggingface.co/spaces/VIDraft/money-radar",
63
+ "https://huggingface.co/spaces/immunobiotech/drug-discovery",
64
+ "https://huggingface.co/spaces/immunobiotech/Gemini-MICHELIN",
65
+ "https://huggingface.co/spaces/openfree/Cycle-Navigator",
66
+ "https://huggingface.co/spaces/VIDraft/Fashion-Fit",
67
+ "https://huggingface.co/spaces/openfree/Stock-Trading-Analysis",
68
+ "https://huggingface.co/spaces/ginipick/AgentX-Papers",
69
+ "https://huggingface.co/spaces/Heartsync/Papers-Leaderboard",
70
+ "https://huggingface.co/spaces/VIDraft/PapersImpact",
71
+ "https://huggingface.co/spaces/ginigen/multimodal-chat-mbti-korea",
72
+ ],
73
+ "Image": [
74
+ "https://huggingface.co/spaces/ginigen/FLUX-Ghibli-LoRA2",
75
+ "https://huggingface.co/spaces/aiqcamp/REMOVAL-TEXT-IMAGE",
76
+ "https://huggingface.co/spaces/VIDraft/BAGEL-Websearch",
77
+ "https://huggingface.co/spaces/ginigen/Every-Text",
78
+ "https://huggingface.co/spaces/ginigen/text3d-r1",
79
+ "https://huggingface.co/spaces/ginipick/FLUXllama",
80
+ "https://huggingface.co/spaces/ginigen/Workflow-Canvas",
81
+ "https://huggingface.co/spaces/ginigen/canvas-studio",
82
+ "https://huggingface.co/spaces/VIDraft/ReSize-Image-Outpainting",
83
+ "https://huggingface.co/spaces/Heartsync/FLUX-Vision",
84
+ "https://huggingface.co/spaces/fantos/textcutobject",
85
+ "https://huggingface.co/spaces/aiqtech/imaginpaint",
86
+ "https://huggingface.co/spaces/openfree/ColorRevive",
87
+ "https://huggingface.co/spaces/openfree/ultpixgen",
88
+ "https://huggingface.co/spaces/VIDraft/Polaroid-Style",
89
+ "https://huggingface.co/spaces/ginigen/VisualCloze",
90
+ "https://huggingface.co/spaces/fantaxy/ofai-flx-logo",
91
+ "https://huggingface.co/spaces/ginigen/interior-design",
92
+ "https://huggingface.co/spaces/ginigen/MagicFace-V3",
93
+ "https://huggingface.co/spaces/fantaxy/flx-pulid",
94
+ "https://huggingface.co/spaces/seawolf2357/Ghibli-Multilingual-Text-rendering",
95
+ "https://huggingface.co/spaces/VIDraft/Open-Meme-Studio",
96
+ "https://huggingface.co/spaces/VIDraft/stable-diffusion-3.5-large-turboX",
97
+ "https://huggingface.co/spaces/aiqtech/flxgif",
98
+ "https://huggingface.co/spaces/openfree/VectorFlow",
99
+
100
+ # "https://huggingface.co/spaces/ginigen/3D-LLAMA",
101
+ # "https://huggingface.co/spaces/ginigen/Multi-LoRAgen",
102
+
103
+ ],
104
+ "LLM / VLM": [
105
+ "https://huggingface.co/spaces/ginigen/deepseek-r1-0528-API",
106
+ "https://huggingface.co/spaces/aiqcamp/Mistral-Devstral-API"
107
+ "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528",
108
+ "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528-qwen3-8b",
109
+ "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528",
110
+ "https://huggingface.co/spaces/aiqcamp/Mistral-Devstral-API",
111
+ "https://huggingface.co/spaces/VIDraft/Mistral-RAG-BitSix",
112
+ "https://huggingface.co/spaces/VIDraft/Gemma-3-R1984-4B",
113
+ "https://huggingface.co/spaces/VIDraft/Gemma-3-R1984-12B",
114
+ "https://huggingface.co/spaces/ginigen/Mistral-Perflexity",
115
+ "https://huggingface.co/spaces/aiqcamp/gemini-2.5-flash-preview",
116
+ "https://huggingface.co/spaces/openfree/qwen3-30b-a3b-research",
117
+ "https://huggingface.co/spaces/openfree/qwen3-235b-a22b-research",
118
+ "https://huggingface.co/spaces/openfree/Llama-4-Maverick-17B-Research",
119
+ ],
120
+ }
121
 
122
+ # ────────────────────────── 3. DATABASE FUNCTIONS ──────────────────────────
123
+ def init_db():
124
+ # Initialize JSON file if it doesn't exist
125
+ if not os.path.exists(DB_FILE):
126
+ with open(DB_FILE, "w", encoding="utf-8") as f:
127
+ json.dump([], f, ensure_ascii=False)
128
+
129
+ # Initialize SQLite database
130
+ conn = sqlite3.connect(SQLITE_DB)
131
+ cursor = conn.cursor()
132
+ cursor.execute('''
133
+ CREATE TABLE IF NOT EXISTS urls (
134
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
135
+ url TEXT UNIQUE NOT NULL,
136
+ date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP
137
+ )
138
+ ''')
139
+ conn.commit()
140
+
141
+ # If we have data in JSON but not in SQLite (first run with new SQLite DB),
142
+ # migrate the data from JSON to SQLite
143
+ json_urls = load_json()
144
+ if json_urls:
145
+ db_urls = load_db_sqlite()
146
+ for url in json_urls:
147
+ if url not in db_urls:
148
+ add_url_to_sqlite(url)
149
+
150
+ conn.close()
151
 
152
+ def load_json():
153
+ """Load URLs from JSON file (for backward compatibility)"""
154
+ try:
155
+ with open(DB_FILE, "r", encoding="utf-8") as f:
156
+ raw = json.load(f)
157
+ return raw if isinstance(raw, list) else []
158
+ except Exception:
159
+ return []
160
 
161
+ def save_json(lst):
162
+ """Save URLs to JSON file (for backward compatibility)"""
163
+ try:
164
+ with open(DB_FILE, "w", encoding="utf-8") as f:
165
+ json.dump(lst, f, ensure_ascii=False, indent=2)
166
+ return True
167
+ except Exception:
168
+ return False
 
169
 
170
+ def load_db_sqlite():
171
+ """Load URLs from SQLite database"""
172
+ conn = sqlite3.connect(SQLITE_DB)
173
+ cursor = conn.cursor()
174
+ cursor.execute("SELECT url FROM urls ORDER BY date_added DESC")
175
+ urls = [row[0] for row in cursor.fetchall()]
176
+ conn.close()
177
+ return urls
178
 
179
+ def add_url_to_sqlite(url):
180
+ """Add a URL to SQLite database"""
181
+ conn = sqlite3.connect(SQLITE_DB)
182
+ cursor = conn.cursor()
183
+ try:
184
+ cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,))
185
+ conn.commit()
186
+ success = True
187
+ except sqlite3.IntegrityError:
188
+ # URL already exists
189
+ success = False
190
+ conn.close()
191
+ return success
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
 
193
+ def update_url_in_sqlite(old_url, new_url):
194
+ """Update a URL in SQLite database"""
195
+ conn = sqlite3.connect(SQLITE_DB)
196
+ cursor = conn.cursor()
197
+ try:
198
+ cursor.execute("UPDATE urls SET url = ? WHERE url = ?", (new_url, old_url))
199
+ if cursor.rowcount > 0:
200
+ conn.commit()
201
+ success = True
202
+ else:
203
+ success = False
204
+ except sqlite3.IntegrityError:
205
+ # New URL already exists
206
+ success = False
207
+ conn.close()
208
+ return success
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
+ def delete_url_from_sqlite(url):
211
+ """Delete a URL from SQLite database"""
212
+ conn = sqlite3.connect(SQLITE_DB)
213
+ cursor = conn.cursor()
214
+ cursor.execute("DELETE FROM urls WHERE url = ?", (url,))
215
+ if cursor.rowcount > 0:
216
+ conn.commit()
217
+ success = True
218
+ else:
219
+ success = False
220
+ conn.close()
221
+ return success
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
+ def load_db():
224
+ """Primary function to load URLs - prioritizes SQLite DB but falls back to JSON"""
225
+ urls = load_db_sqlite()
226
+ if not urls:
227
+ # If SQLite DB is empty, try loading from JSON
228
+ urls = load_json()
229
+ # If we found URLs in JSON, migrate them to SQLite
230
+ for url in urls:
231
+ add_url_to_sqlite(url)
232
+ return urls
233
 
234
+ def save_db(lst):
235
+ """Save URLs to both SQLite and JSON"""
236
+ # Get existing URLs from SQLite for comparison
237
+ existing_urls = load_db_sqlite()
238
+
239
+ # Clear all URLs from SQLite and add the new list
240
+ conn = sqlite3.connect(SQLITE_DB)
241
+ cursor = conn.cursor()
242
+ cursor.execute("DELETE FROM urls")
243
+ for url in lst:
244
+ cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,))
245
+ conn.commit()
246
+ conn.close()
247
+
248
+ # Also save to JSON for backward compatibility
249
+ return save_json(lst)
250
 
251
+ # ────────────────────────── 4. URL HELPERS ──────────────────────────
252
+ def direct_url(hf_url):
253
+ m = re.match(r"https?://huggingface\.co/spaces/([^/]+)/([^/?#]+)", hf_url)
254
+ if not m:
255
+ return hf_url
256
+ owner, name = m.groups()
257
+ owner = owner.lower()
258
+ name = name.replace('.', '-').replace('_', '-').lower()
259
+ return f"https://{owner}-{name}.hf.space"
260
 
261
+ def screenshot_url(url):
262
+ return f"https://image.thum.io/get/fullpage/{url}"
 
 
 
263
 
264
+ def process_url_for_preview(url):
265
+ """Returns (preview_url, mode)"""
266
+ # Handle blocked domains first
267
+ if any(d for d in BLOCKED_DOMAINS if d in url):
268
+ return screenshot_url(url), "snapshot"
269
+
270
+ # Special case handling for problematic URLs
271
+ if "vibe-coding-tetris" in url or "World-of-Tank-GAME" in url or "Minesweeper-Game" in url:
272
+ return screenshot_url(url), "snapshot"
273
+
274
+ # General HF space handling
275
+ try:
276
+ if "huggingface.co/spaces" in url:
277
+ parts = url.rstrip("/").split("/")
278
+ if len(parts) >= 5:
279
+ owner = parts[-2]
280
+ name = parts[-1]
281
+ embed_url = f"https://huggingface.co/spaces/{owner}/{name}/embed"
282
+ return embed_url, "iframe"
283
+ except Exception:
284
+ return screenshot_url(url), "snapshot"
285
+
286
+ # Default handling
287
+ return url, "iframe"
288
 
289
+ # ────────────────────────── 5. API ROUTES ──────────────────────────
290
+ @app.route('/api/category')
291
+ def api_category():
292
+ cat = request.args.get('name', '')
293
+ urls = CATEGORIES.get(cat, [])
294
+
295
+ # Add pagination for categories as well
296
+ page = int(request.args.get('page', 1))
297
+ per_page = int(request.args.get('per_page', 4)) # Changed to 4 per page
298
+
299
+ total_pages = max(1, (len(urls) + per_page - 1) // per_page)
300
+ start = (page - 1) * per_page
301
+ end = min(start + per_page, len(urls))
302
+
303
+ urls_page = urls[start:end]
304
+
305
+ items = [
306
+ {
307
+ "title": url.split('/')[-1],
308
+ "owner": url.split('/')[-2] if '/spaces/' in url else '',
309
+ "iframe": direct_url(url),
310
+ "shot": screenshot_url(url),
311
+ "hf": url
312
+ } for url in urls_page
313
+ ]
314
+
315
+ return jsonify({
316
+ "items": items,
317
+ "page": page,
318
+ "total_pages": total_pages
319
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
+ @app.route('/api/favorites')
322
+ def api_favorites():
323
+ # Load URLs from SQLite database
324
+ urls = load_db()
325
+
326
+ page = int(request.args.get('page', 1))
327
+ per_page = int(request.args.get('per_page', 4)) # Changed to 4 per page
328
+
329
+ total_pages = max(1, (len(urls) + per_page - 1) // per_page)
330
+ start = (page - 1) * per_page
331
+ end = min(start + per_page, len(urls))
332
+
333
+ urls_page = urls[start:end]
334
+
335
+ result = []
336
+ for url in urls_page:
337
+ try:
338
+ preview_url, mode = process_url_for_preview(url)
339
+ result.append({
340
+ "title": url.split('/')[-1],
341
+ "url": url,
342
+ "preview_url": preview_url,
343
+ "mode": mode
344
+ })
345
+ except Exception:
346
+ # Fallback to screenshot mode
347
+ result.append({
348
+ "title": url.split('/')[-1],
349
+ "url": url,
350
+ "preview_url": screenshot_url(url),
351
+ "mode": "snapshot"
352
+ })
353
+
354
+ return jsonify({
355
+ "items": result,
356
+ "page": page,
357
+ "total_pages": total_pages
358
+ })
359
 
360
+ @app.route('/api/url/add', methods=['POST'])
361
+ def add_url():
362
+ url = request.form.get('url', '').strip()
363
+ if not url:
364
+ return jsonify({"success": False, "message": "URL is required"})
365
+
366
+ # SQLite에 추가 시도
367
+ conn = sqlite3.connect(SQLITE_DB)
368
+ cursor = conn.cursor()
369
+ try:
370
+ cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,))
371
+ conn.commit()
372
+ success = True
373
+ except sqlite3.IntegrityError:
374
+ # URL이 이미 존재하는 경우
375
+ success = False
376
+ except Exception as e:
377
+ print(f"SQLite error: {str(e)}")
378
+ success = False
379
+ finally:
380
+ conn.close()
381
+
382
+ if not success:
383
+ return jsonify({"success": False, "message": "URL already exists or could not be added"})
384
+
385
+ # JSON 파일에도 추가 (백업용)
386
+ data = load_json()
387
+ if url not in data:
388
+ data.insert(0, url)
389
+ save_json(data)
390
+
391
+ return jsonify({"success": True, "message": "URL added successfully"})
392
 
393
+ @app.route('/api/url/update', methods=['POST'])
394
+ def update_url():
395
+ old = request.form.get('old', '')
396
+ new = request.form.get('new', '').strip()
397
+
398
+ if not new:
399
+ return jsonify({"success": False, "message": "New URL is required"})
400
+
401
+ # Update in SQLite DB
402
+ if not update_url_in_sqlite(old, new):
403
+ return jsonify({"success": False, "message": "URL not found or new URL already exists"})
404
+
405
+ # Also update JSON file for backward compatibility
406
+ data = load_json()
407
+ try:
408
+ idx = data.index(old)
409
+ data[idx] = new
410
+ save_json(data)
411
+ except ValueError:
412
+ # If URL not in JSON, add it
413
+ data.append(new)
414
+ save_json(data)
415
+
416
+ return jsonify({"success": True, "message": "URL updated successfully"})
417
+
418
+ @app.route('/api/url/delete', methods=['POST'])
419
+ def delete_url():
420
+ url = request.form.get('url', '')
421
+
422
+ # Delete from SQLite DB
423
+ if not delete_url_from_sqlite(url):
424
+ return jsonify({"success": False, "message": "URL not found"})
425
+
426
+ # Also update JSON file for backward compatibility
427
+ data = load_json()
428
+ try:
429
+ data.remove(url)
430
+ save_json(data)
431
+ except ValueError:
432
+ pass
433
+
434
+ return jsonify({"success": True, "message": "URL deleted successfully"})
435
+
436
+ # ────────────────────────── 6. MAIN ROUTES ───────────��──────────────
437
+ @app.route('/')
438
+ def home():
439
+ os.makedirs('templates', exist_ok=True)
440
+
441
+ with open('templates/index.html', 'w', encoding='utf-8') as fp:
442
+ fp.write(r'''<!DOCTYPE html>
443
+ <html>
444
+ <head>
445
+ <meta charset="utf-8">
446
+ <meta name="viewport" content="width=device-width, initial-scale=1">
447
+ <title>Web Gallery</title>
448
+ <style>
449
+ @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;600&display=swap');
450
+ body{margin:0;font-family:Nunito,sans-serif;background:#f6f8fb;}
451
+ .tabs{display:flex;flex-wrap:wrap;gap:8px;padding:16px;}
452
+ .tab{padding:6px 14px;border:none;border-radius:18px;background:#e2e8f0;font-weight:600;cursor:pointer;}
453
+ .tab.active{background:#a78bfa;color:#1a202c;}
454
+ .tab.manage{background:#ff6e91;color:white;}
455
+ .tab.manage.active{background:#ff2d62;color:white;}
456
+ /* Updated grid to show 2x2 layout */
457
+ .grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;padding:0 16px 60px;max-width:1200px;margin:0 auto;}
458
+ @media(max-width:800px){.grid{grid-template-columns:1fr;}}
459
+ /* Increased card height for larger display */
460
+ .card{background:#fff;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.08);overflow:hidden;height:540px;display:flex;flex-direction:column;position:relative;}
461
+ .frame{flex:1;position:relative;overflow:hidden;}
462
+ .frame iframe{position:absolute;width:166.667%;height:166.667%;transform:scale(.6);transform-origin:top left;border:0;}
463
+ .frame img{width:100%;height:100%;object-fit:cover;}
464
+ .card-label{position:absolute;top:10px;left:10px;padding:4px 8px;border-radius:4px;font-size:11px;font-weight:bold;z-index:100;text-transform:uppercase;letter-spacing:0.5px;box-shadow:0 2px 4px rgba(0,0,0,0.2);}
465
+ .label-live{background:linear-gradient(135deg, #00c6ff, #0072ff);color:white;}
466
+ .label-static{background:linear-gradient(135deg, #ff9a9e, #fad0c4);color:#333;}
467
+ .foot{height:44px;background:#fafafa;display:flex;align-items:center;justify-content:center;border-top:1px solid #eee;}
468
+ .foot a{font-size:.82rem;font-weight:700;color:#4a6dd8;text-decoration:none;}
469
+ .pagination{display:flex;justify-content:center;margin:20px 0;gap:10px;}
470
+ .pagination button{padding:5px 15px;border:none;border-radius:20px;background:#e2e8f0;cursor:pointer;}
471
+ .pagination button:disabled{opacity:0.5;cursor:not-allowed;}
472
+ .manage-panel{background:white;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.08);margin:16px;padding:20px;}
473
+ .form-group{margin-bottom:15px;}
474
+ .form-group label{display:block;margin-bottom:5px;font-weight:600;}
475
+ .form-control{width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;}
476
+ .btn{padding:8px 15px;border:none;border-radius:4px;cursor:pointer;font-weight:600;}
477
+ .btn-primary{background:#4a6dd8;color:white;}
478
+ .btn-danger{background:#e53e3e;color:white;}
479
+ .btn-success{background:#38a169;color:white;}
480
+ .status{padding:10px;margin:10px 0;border-radius:4px;display:none;}
481
+ .status.success{display:block;background:#c6f6d5;color:#22543d;}
482
+ .status.error{display:block;background:#fed7d7;color:#822727;}
483
+ .url-list{margin:20px 0;border:1px solid #eee;border-radius:4px;max-height:300px;overflow-y:auto;}
484
+ .url-item{padding:10px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;}
485
+ .url-item:last-child{border-bottom:none;}
486
+ .url-controls{display:flex;gap:5px;}
487
+ </style>
488
+ </head>
489
+ <body>
490
+ <header style="text-align: center; padding: 20px; background: linear-gradient(135deg, #f6f8fb, #e2e8f0); border-bottom: 1px solid #ddd;">
491
+ <h1 style="margin-bottom: 10px;">🌟Open Free AI Playground</h1>
492
+ <p>
493
+ <a href="https://discord.gg/openfreeai" target="_blank"><img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%230000ff&labelColor=%23800080&logo=discord&logoColor=white&style=for-the-badge" alt="badge"></a>
494
+ </p>
495
+ </header>
496
+ <div class="tabs" id="tabs"></div>
497
+ <div id="content"></div>
498
+ <script>
499
+ // Basic configuration
500
+ const cats = {{cats|tojson}};
501
+ const tabs = document.getElementById('tabs');
502
+ const content = document.getElementById('content');
503
+ let active = "";
504
+ let currentPage = 1;
505
+ // Simple utility functions
506
+ function loadHTML(url, callback) {
507
+ const xhr = new XMLHttpRequest();
508
+ xhr.open('GET', url, true);
509
+ xhr.onreadystatechange = function() {
510
+ if (xhr.readyState === 4 && xhr.status === 200) {
511
+ callback(xhr.responseText);
512
+ }
513
+ };
514
+ xhr.send();
515
+ }
516
+ function makeRequest(url, method, data, callback) {
517
+ const xhr = new XMLHttpRequest();
518
+ xhr.open(method, url, true);
519
+ xhr.onreadystatechange = function() {
520
+ if (xhr.readyState === 4 && xhr.status === 200) {
521
+ callback(JSON.parse(xhr.responseText));
522
+ }
523
+ };
524
+ if (method === 'POST') {
525
+ xhr.send(data);
526
+ } else {
527
+ xhr.send();
528
+ }
529
+ }
530
+ function updateTabs() {
531
+ Array.from(tabs.children).forEach(b => {
532
+ b.classList.toggle('active', b.dataset.c === active);
533
+ });
534
+ }
535
+ // Tab handlers
536
+ function loadCategory(cat, page) {
537
+ if(cat === active && currentPage === page) return;
538
+ active = cat;
539
+ currentPage = page || 1;
540
+ updateTabs();
541
+
542
+ content.innerHTML = '<p style="text-align:center;padding:40px">Loading…</p>';
543
+
544
+ makeRequest('/api/category?name=' + encodeURIComponent(cat) + '&page=' + currentPage + '&per_page=4', 'GET', null, function(data) {
545
+ let html = '<div class="grid">';
546
+
547
+ if(data.items.length === 0) {
548
+ html += '<p style="grid-column:1/-1;text-align:center;padding:40px">No items in this category.</p>';
549
+ } else {
550
+ data.items.forEach(item => {
551
+ html += `
552
+ <div class="card">
553
+ <div class="card-label label-live">LIVE</div>
554
+ <div class="frame">
555
+ <iframe src="${item.iframe}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>
556
+ </div>
557
+ <div class="foot">
558
+ <a href="${item.hf}" target="_blank">${item.title}</a>
559
+ </div>
560
+ </div>
561
+ `;
562
+ });
563
+ }
564
+
565
+ html += '</div>';
566
+
567
+ // Add pagination
568
+ html += `
569
+ <div class="pagination">
570
+ <button ${currentPage <= 1 ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage-1})">« Previous</button>
571
+ <span>Page ${currentPage} of ${data.total_pages}</span>
572
+ <button ${currentPage >= data.total_pages ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage+1})">Next »</button>
573
+ </div>
574
+ `;
575
+
576
+ content.innerHTML = html;
577
+ });
578
+ }
579
+ function loadFavorites(page) {
580
+ if(active === 'Favorites' && currentPage === page) return;
581
+ active = 'Favorites';
582
+ currentPage = page || 1;
583
+ updateTabs();
584
+
585
+ content.innerHTML = '<p style="text-align:center;padding:40px">Loading…</p>';
586
+
587
+ makeRequest('/api/favorites?page=' + currentPage + '&per_page=4', 'GET', null, function(data) {
588
+ let html = '<div class="grid">';
589
+
590
+ if(data.items.length === 0) {
591
+ html += '<p style="grid-column:1/-1;text-align:center;padding:40px">No favorites saved yet.</p>';
592
+ } else {
593
+ data.items.forEach(item => {
594
+ if(item.mode === 'snapshot') {
595
+ html += `
596
+ <div class="card">
597
+ <div class="card-label label-static">Static</div>
598
+ <div class="frame">
599
+ <img src="${item.preview_url}" loading="lazy">
600
+ </div>
601
+ <div class="foot">
602
+ <a href="${item.url}" target="_blank">${item.title}</a>
603
+ </div>
604
+ </div>
605
+ `;
606
+ } else {
607
+ html += `
608
+ <div class="card">
609
+ <div class="card-label label-live">LIVE</div>
610
+ <div class="frame">
611
+ <iframe src="${item.preview_url}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>
612
+ </div>
613
+ <div class="foot">
614
+ <a href="${item.url}" target="_blank">${item.title}</a>
615
+ </div>
616
+ </div>
617
+ `;
618
+ }
619
+ });
620
+ }
621
+
622
+ html += '</div>';
623
+
624
+ // Add pagination
625
+ html += `
626
+ <div class="pagination">
627
+ <button ${currentPage <= 1 ? 'disabled' : ''} onclick="loadFavorites(${currentPage-1})">« Previous</button>
628
+ <span>Page ${currentPage} of ${data.total_pages}</span>
629
+ <button ${currentPage >= data.total_pages ? 'disabled' : ''} onclick="loadFavorites(${currentPage+1})">Next »</button>
630
+ </div>
631
+ `;
632
+
633
+ content.innerHTML = html;
634
+ });
635
+ }
636
+ function loadManage() {
637
+ if(active === 'Manage') return;
638
+ active = 'Manage';
639
+ updateTabs();
640
+
641
+ content.innerHTML = `
642
+ <div class="manage-panel">
643
+ <h2>Add New URL</h2>
644
+ <div class="form-group">
645
+ <label for="new-url">URL</label>
646
+ <input type="text" id="new-url" class="form-control" placeholder="https://example.com">
647
+ </div>
648
+ <button onclick="addUrl()" class="btn btn-primary">Add URL</button>
649
+ <div id="add-status" class="status"></div>
650
+
651
+ <h2>Manage Saved URLs</h2>
652
+ <div id="url-list" class="url-list">Loading...</div>
653
+ </div>
654
+ `;
655
+
656
+ loadUrlList();
657
+ }
658
+ // URL management functions
659
+ function loadUrlList() {
660
+ makeRequest('/api/favorites?per_page=100', 'GET', null, function(data) {
661
+ const urlList = document.getElementById('url-list');
662
+
663
+ if(data.items.length === 0) {
664
+ urlList.innerHTML = '<p style="text-align:center;padding:20px">No URLs saved yet.</p>';
665
+ return;
666
+ }
667
+
668
+ let html = '';
669
+ data.items.forEach(item => {
670
+ // Escape the URL to prevent JavaScript injection when used in onclick handlers
671
+ const escapedUrl = item.url.replace(/'/g, "\\'");
672
+
673
+ html += `
674
+ <div class="url-item">
675
+ <div>${item.url}</div>
676
+ <div class="url-controls">
677
+ <button class="btn" onclick="editUrl('${escapedUrl}')">Edit</button>
678
+ <button class="btn btn-danger" onclick="deleteUrl('${escapedUrl}')">Delete</button>
679
+ </div>
680
+ </div>
681
+ `;
682
+ });
683
+
684
+ urlList.innerHTML = html;
685
+ });
686
+ }
687
+ function addUrl() {
688
+ const url = document.getElementById('new-url').value.trim();
689
+
690
+ if(!url) {
691
+ showStatus('add-status', 'Please enter a URL', false);
692
+ return;
693
+ }
694
+
695
+ const formData = new FormData();
696
+ formData.append('url', url);
697
+
698
+ makeRequest('/api/url/add', 'POST', formData, function(data) {
699
+ showStatus('add-status', data.message, data.success);
700
+ if(data.success) {
701
+ document.getElementById('new-url').value = '';
702
+ loadUrlList();
703
+ // If currently in Favorites tab, reload to see changes immediately
704
+ if(active === 'Favorites') {
705
+ loadFavorites(currentPage);
706
+ }
707
+ }
708
+ });
709
+ }
710
+ function editUrl(url) {
711
+ // Decode URL if it was previously escaped
712
+ const decodedUrl = url.replace(/\\'/g, "'");
713
+ const newUrl = prompt('Edit URL:', decodedUrl);
714
+
715
+ if(!newUrl || newUrl === decodedUrl) return;
716
+
717
+ const formData = new FormData();
718
+ formData.append('old', decodedUrl);
719
+ formData.append('new', newUrl);
720
+
721
+ makeRequest('/api/url/update', 'POST', formData, function(data) {
722
+ if(data.success) {
723
+ loadUrlList();
724
+ // If currently in Favorites tab, reload to see changes immediately
725
+ if(active === 'Favorites') {
726
+ loadFavorites(currentPage);
727
+ }
728
+ } else {
729
+ alert(data.message);
730
+ }
731
+ });
732
+ }
733
+ function deleteUrl(url) {
734
+ // Decode URL if it was previously escaped
735
+ const decodedUrl = url.replace(/\\'/g, "'");
736
+ if(!confirm('Are you sure you want to delete this URL?')) return;
737
+
738
+ const formData = new FormData();
739
+ formData.append('url', decodedUrl);
740
+
741
+ makeRequest('/api/url/delete', 'POST', formData, function(data) {
742
+ if(data.success) {
743
+ loadUrlList();
744
+ // If currently in Favorites tab, reload to see changes immediately
745
+ if(active === 'Favorites') {
746
+ loadFavorites(currentPage);
747
+ }
748
+ } else {
749
+ alert(data.message);
750
+ }
751
+ });
752
+ }
753
+ function showStatus(id, message, success) {
754
+ const status = document.getElementById(id);
755
+ status.textContent = message;
756
+ status.className = success ? 'status success' : 'status error';
757
+ setTimeout(() => {
758
+ status.className = 'status';
759
+ }, 3000);
760
+ }
761
+ // Create tabs
762
+ // Favorites tab first
763
+ const favTab = document.createElement('button');
764
+ favTab.className = 'tab';
765
+ favTab.textContent = 'Favorites';
766
+ favTab.dataset.c = 'Favorites';
767
+ favTab.onclick = function() { loadFavorites(1); };
768
+ tabs.appendChild(favTab);
769
+ // Category tabs
770
+ cats.forEach(c => {
771
+ const b = document.createElement('button');
772
+ b.className = 'tab';
773
+ b.textContent = c;
774
+ b.dataset.c = c;
775
+ b.onclick = function() { loadCategory(c, 1); };
776
+ tabs.appendChild(b);
777
+ });
778
+ // Manage tab last
779
+ const manageTab = document.createElement('button');
780
+ manageTab.className = 'tab manage';
781
+ manageTab.textContent = 'Manage';
782
+ manageTab.dataset.c = 'Manage';
783
+ manageTab.onclick = function() { loadManage(); };
784
+ tabs.appendChild(manageTab);
785
+ // Start with Favorites tab
786
+ loadFavorites(1);
787
+ </script>
788
+ </body>
789
+ </html>''')
790
+
791
+ # Return the rendered template
792
+ return render_template('index.html', cats=list(CATEGORIES.keys()))
793
+
794
+ # Initialize database on startup
795
+ init_db()
796
 
797
+ # Define a function to ensure database consistency
798
+ def ensure_db_consistency():
799
+ # Make sure we have the latest data in both JSON and SQLite
800
+ urls = load_db_sqlite()
801
+ save_json(urls)
802
+
803
+ # For Flask 2.0+ compatibility
804
+ @app.before_request
805
+ def before_request_func():
806
+ # Use a flag to run this only once
807
+ if not hasattr(app, '_got_first_request'):
808
+ ensure_db_consistency()
809
+ app._got_first_request = True
810
+
811
+ if __name__ == '__main__':
812
+ # 앱 시작 전에 명시적으로 DB 초기화
813
+ print("Initializing database...")
814
+ init_db()
815
+
816
+ # 데이터베이스 파일 경로 및 존재 여부 확인
817
+ db_path = os.path.abspath(SQLITE_DB)
818
+ print(f"SQLite DB path: {db_path}")
819
+ if os.path.exists(SQLITE_DB):
820
+ print(f"Database file exists, size: {os.path.getsize(SQLITE_DB)} bytes")
821
+ else:
822
+ print("Warning: Database file does not exist after initialization!")
823
+
824
+ app.run(host='0.0.0.0', port=7860)