GitHub Actions
commited on
Commit
Β·
c0c176a
1
Parent(s):
8bd258a
Auto-deploy from GitHub Actions - 2025-12-17 02:43:31
Browse files- app/__init__.py +25 -7
- app/database.py +21 -0
- app/gemini_client.py +9 -2
- app/routes.py +100 -0
- templates/admin_webtoon_planner.html +35 -5
- templates/admin_webtoon_stage_keys.html +223 -0
- templates/admin_webtoon_stage_planner.html +35 -8
app/__init__.py
CHANGED
|
@@ -131,6 +131,10 @@ def create_app() -> Flask:
|
|
| 131 |
def _is_postgres_uri(uri: str) -> bool:
|
| 132 |
return uri.startswith('postgresql://') or uri.startswith('postgres://')
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
def _normalize_postgres_uri(uri: str) -> str:
|
| 135 |
"""
|
| 136 |
- postgres:// -> postgresql:// λ‘ μ κ·ν
|
|
@@ -159,8 +163,13 @@ def create_app() -> Flask:
|
|
| 159 |
new_query = urlencode(q) if q else ''
|
| 160 |
return urlunparse(parsed._replace(query=new_query))
|
| 161 |
|
| 162 |
-
# NOTE
|
| 163 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
def _retry_sleep_seconds(attempt_idx: int) -> float:
|
| 166 |
"""
|
|
@@ -214,11 +223,20 @@ def create_app() -> Flask:
|
|
| 214 |
# SQLite ν΄λ°±μ νμ§ μκ³ , μλ²λ μ΄λ €λκ³ μλ¬ νμ΄μ§λ‘ μλ΄
|
| 215 |
_mark_db_unavailable(last_err, phase="connect_test")
|
| 216 |
else:
|
| 217 |
-
#
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = engine_options
|
| 224 |
|
|
|
|
| 131 |
def _is_postgres_uri(uri: str) -> bool:
|
| 132 |
return uri.startswith('postgresql://') or uri.startswith('postgres://')
|
| 133 |
|
| 134 |
+
def _is_sqlite_uri(uri: str) -> bool:
|
| 135 |
+
# flask-sqlalchemy/sqlalchemyμμ νν μ°λ sqlite URI νμλ€ νμ©
|
| 136 |
+
return uri.startswith('sqlite:///') or uri.startswith('sqlite://')
|
| 137 |
+
|
| 138 |
def _normalize_postgres_uri(uri: str) -> str:
|
| 139 |
"""
|
| 140 |
- postgres:// -> postgresql:// λ‘ μ κ·ν
|
|
|
|
| 163 |
new_query = urlencode(q) if q else ''
|
| 164 |
return urlunparse(parsed._replace(query=new_query))
|
| 165 |
|
| 166 |
+
# NOTE:
|
| 167 |
+
# - λ‘컬/κ°λ° νκ²½μμλ Config κΈ°λ³Έκ°(SQLite)μ μ μ νμ©ν©λλ€.
|
| 168 |
+
# - μ΄μμμ "PostgreSQLλ§ κ°μ "νκ³ μΆλ€λ©΄ νκ²½λ³μλ‘ strict λͺ¨λλ₯Ό μΌλλ€.
|
| 169 |
+
# (μ: REQUIRE_POSTGRES=1)
|
| 170 |
+
def _require_postgres_only() -> bool:
|
| 171 |
+
v = (os.environ.get('REQUIRE_POSTGRES') or os.environ.get('DB_STRICT_MODE') or '').strip().lower()
|
| 172 |
+
return v in ('1', 'true', 'yes', 'y', 'on')
|
| 173 |
|
| 174 |
def _retry_sleep_seconds(attempt_idx: int) -> float:
|
| 175 |
"""
|
|
|
|
| 223 |
# SQLite ν΄λ°±μ νμ§ μκ³ , μλ²λ μ΄λ €λκ³ μλ¬ νμ΄μ§λ‘ μλ΄
|
| 224 |
_mark_db_unavailable(last_err, phase="connect_test")
|
| 225 |
else:
|
| 226 |
+
# SQLiteλ λ‘컬 κΈ°λ³Έκ°μΌλ‘ νμ© (strict λͺ¨λκ° μλλ©΄ μ μ λΆν
)
|
| 227 |
+
if _is_sqlite_uri(effective_db_uri) and not _require_postgres_only():
|
| 228 |
+
try:
|
| 229 |
+
logger.info(f"[λ°μ΄ν°λ² μ΄μ€] SQLite μ¬μ©: {effective_db_uri}")
|
| 230 |
+
except Exception:
|
| 231 |
+
# λ‘κΉ
μ€ν¨λ 무μ (λΆν
μ μν₯ μ£Όμ§ μμ)
|
| 232 |
+
pass
|
| 233 |
+
else:
|
| 234 |
+
# μΈλΆ DB(DATABASE_URL)κ° μκ±°λ postgres νμμ΄ μλ κ²½μ°:
|
| 235 |
+
# strict λͺ¨λμμλ DB μ¬μ© λΆκ°λ‘ κ°μ£Όνμ¬ μ₯μ λͺ¨λλ‘ μ ν
|
| 236 |
+
_mark_db_unavailable(
|
| 237 |
+
ValueError("DATABASE_URLμ΄ μκ±°λ PostgreSQL νμμ΄ μλλλ€. (REQUIRE_POSTGRES=1μ΄λ©΄ SQLite μ¬μ© κΈμ§)"),
|
| 238 |
+
phase="invalid_db_uri",
|
| 239 |
+
)
|
| 240 |
|
| 241 |
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = engine_options
|
| 242 |
|
app/database.py
CHANGED
|
@@ -627,3 +627,24 @@ class WebtoonDurationJob(db.Model):
|
|
| 627 |
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
| 628 |
}
|
| 629 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
| 628 |
}
|
| 629 |
|
| 630 |
+
|
| 631 |
+
class WebtoonStageKeyMapping(db.Model):
|
| 632 |
+
"""μμ
λ¨κ³ ν€(μ: 02, 03-2) -> νμμ© λ¨κ³λͺ
(μ: 02. μ½ν°) λ§€ν"""
|
| 633 |
+
__tablename__ = 'webtoon_stage_key_mapping'
|
| 634 |
+
|
| 635 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 636 |
+
stage_key = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
| 637 |
+
label = db.Column(db.String(200), nullable=False)
|
| 638 |
+
|
| 639 |
+
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
| 640 |
+
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
| 641 |
+
|
| 642 |
+
def to_dict(self):
|
| 643 |
+
return {
|
| 644 |
+
'id': self.id,
|
| 645 |
+
'stage_key': self.stage_key,
|
| 646 |
+
'label': self.label,
|
| 647 |
+
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 648 |
+
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
| 649 |
+
}
|
| 650 |
+
|
app/gemini_client.py
CHANGED
|
@@ -19,6 +19,15 @@ def get_gemini_api_key():
|
|
| 19 |
if api_key:
|
| 20 |
print(f"[Gemini] νκ²½ λ³μμμ API ν€ κ°μ Έμ΄ (κΈΈμ΄: {len(api_key)}μ)")
|
| 21 |
return api_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# DBμμ κ°μ Έμ€κΈ° (μν μ°Έμ‘° λ°©μ§λ₯Ό μν΄ μ¬κΈ°μ μν¬νΈ)
|
| 24 |
try:
|
|
@@ -33,8 +42,6 @@ def get_gemini_api_key():
|
|
| 33 |
print(f"[Gemini] DBμμ API ν€ μ‘°ν μ€ν¨: {e}")
|
| 34 |
return ''
|
| 35 |
|
| 36 |
-
GEMINI_API_KEY = get_gemini_api_key()
|
| 37 |
-
|
| 38 |
# μ¬μ© κ°λ₯ν Gemini λͺ¨λΈ λͺ©λ‘ (μ΅μ λ²μ μ°μ )
|
| 39 |
AVAILABLE_GEMINI_MODELS = [
|
| 40 |
'gemini-2.0-flash-exp',
|
|
|
|
| 19 |
if api_key:
|
| 20 |
print(f"[Gemini] νκ²½ λ³μμμ API ν€ κ°μ Έμ΄ (κΈΈμ΄: {len(api_key)}μ)")
|
| 21 |
return api_key
|
| 22 |
+
|
| 23 |
+
# μ± μ»¨ν
μ€νΈ λ°(λͺ¨λ import μμ /μ€ν¬λ¦½νΈ μ€ν λ±)μμλ DB μ‘°νλ₯Ό μλνμ§ μμ
|
| 24 |
+
# (SystemConfig.queryκ° current_app/app_contextλ₯Ό νμλ‘ νλ―λ‘)
|
| 25 |
+
try:
|
| 26 |
+
from flask import has_app_context
|
| 27 |
+
if not has_app_context():
|
| 28 |
+
return ''
|
| 29 |
+
except Exception:
|
| 30 |
+
return ''
|
| 31 |
|
| 32 |
# DBμμ κ°μ Έμ€κΈ° (μν μ°Έμ‘° λ°©μ§λ₯Ό μν΄ μ¬κΈ°μ μν¬νΈ)
|
| 33 |
try:
|
|
|
|
| 42 |
print(f"[Gemini] DBμμ API ν€ μ‘°ν μ€ν¨: {e}")
|
| 43 |
return ''
|
| 44 |
|
|
|
|
|
|
|
| 45 |
# μ¬μ© κ°λ₯ν Gemini λͺ¨λΈ λͺ©λ‘ (μ΅μ λ²μ μ°μ )
|
| 46 |
AVAILABLE_GEMINI_MODELS = [
|
| 47 |
'gemini-2.0-flash-exp',
|
app/routes.py
CHANGED
|
@@ -13,6 +13,7 @@ from app.database import (
|
|
| 13 |
WebtoonProjectUpload, WebtoonProjectTask,
|
| 14 |
WebtoonWBSAnalysis, WebtoonWBSJob,
|
| 15 |
WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
|
|
|
|
| 16 |
)
|
| 17 |
from app.vector_db import get_vector_db
|
| 18 |
from app.gemini_client import get_gemini_client
|
|
@@ -64,6 +65,7 @@ def get_default_admin_menu():
|
|
| 64 |
{"label": "μΌμ μ°μ ", "endpoint": "main.admin_webtoon_project_schedule"},
|
| 65 |
{"label": "μ μ κΈ°κ° ν΅κ³/μ°μ ", "endpoint": "main.admin_webtoon_planner"},
|
| 66 |
{"label": "μ μ λ¨κ³ ν΅κ³/μ°μ ", "endpoint": "main.admin_webtoon_stage_planner"},
|
|
|
|
| 67 |
{"label": "μΉν° WBS", "endpoint": "main.admin_webtoon_wbs"},
|
| 68 |
],
|
| 69 |
},
|
|
@@ -2532,6 +2534,34 @@ def admin_webtoon_stage_planner():
|
|
| 2532 |
return render_template('admin_webtoon_stage_planner.html')
|
| 2533 |
|
| 2534 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2535 |
@main_bp.route('/api/webtoons/project-uploads', methods=['GET'])
|
| 2536 |
@admin_required
|
| 2537 |
def list_webtoon_project_uploads():
|
|
@@ -4107,6 +4137,76 @@ def get_webtoon_stage_episode_duration_stats():
|
|
| 4107 |
return jsonify({'error': f'νμ°¨λ³ μ μλ¨κ³ ν΅κ³ μ‘°ν μ€ μ€λ₯: {str(e)}'}), 500
|
| 4108 |
|
| 4109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4110 |
@main_bp.route('/api/webtoons/duration/stage-plan', methods=['POST'])
|
| 4111 |
@admin_required
|
| 4112 |
def plan_webtoon_duration_by_stage():
|
|
|
|
| 13 |
WebtoonProjectUpload, WebtoonProjectTask,
|
| 14 |
WebtoonWBSAnalysis, WebtoonWBSJob,
|
| 15 |
WebtoonProjectDuration, WebtoonEpisodeDuration, WebtoonDurationJob,
|
| 16 |
+
WebtoonStageKeyMapping,
|
| 17 |
)
|
| 18 |
from app.vector_db import get_vector_db
|
| 19 |
from app.gemini_client import get_gemini_client
|
|
|
|
| 65 |
{"label": "μΌμ μ°μ ", "endpoint": "main.admin_webtoon_project_schedule"},
|
| 66 |
{"label": "μ μ κΈ°κ° ν΅κ³/μ°μ ", "endpoint": "main.admin_webtoon_planner"},
|
| 67 |
{"label": "μ μ λ¨κ³ ν΅κ³/μ°μ ", "endpoint": "main.admin_webtoon_stage_planner"},
|
| 68 |
+
{"label": "λ¨κ³ν€ κ΄λ¦¬ νλ©΄", "endpoint": "main.admin_webtoon_stage_keys"},
|
| 69 |
{"label": "μΉν° WBS", "endpoint": "main.admin_webtoon_wbs"},
|
| 70 |
],
|
| 71 |
},
|
|
|
|
| 2534 |
return render_template('admin_webtoon_stage_planner.html')
|
| 2535 |
|
| 2536 |
|
| 2537 |
+
@main_bp.route('/admin/webtoons/stage-keys')
|
| 2538 |
+
@admin_required
|
| 2539 |
+
def admin_webtoon_stage_keys():
|
| 2540 |
+
"""λ¨κ³ν€(μμ
λ¨κ³ ν€) νμλͺ
λ§€ν κ΄λ¦¬"""
|
| 2541 |
+
return render_template('admin_webtoon_stage_keys.html')
|
| 2542 |
+
|
| 2543 |
+
|
| 2544 |
+
# νΈν/μ€ν λ°©μ§: κΈ°μ‘΄(λλ μ¬μ©μκ° νν μ
λ ₯νλ) λ¨μ κ²½λ‘λ 볡μ κ²½λ‘λ‘ λ¦¬λ€μ΄λ νΈ
|
| 2545 |
+
@main_bp.route('/admin/webtoons/stage-key')
|
| 2546 |
+
@admin_required
|
| 2547 |
+
def admin_webtoon_stage_key_redirect():
|
| 2548 |
+
return redirect(url_for('main.admin_webtoon_stage_keys'))
|
| 2549 |
+
|
| 2550 |
+
|
| 2551 |
+
# νΈν/μ€ν λ°©μ§: keys κ²½λ‘λ stage-keysλ‘ λ¦¬λ€μ΄λ νΈ
|
| 2552 |
+
@main_bp.route('/admin/webtoons/keys')
|
| 2553 |
+
@admin_required
|
| 2554 |
+
def admin_webtoon_keys_redirect():
|
| 2555 |
+
return redirect(url_for('main.admin_webtoon_stage_keys'))
|
| 2556 |
+
|
| 2557 |
+
|
| 2558 |
+
# νΈν/μ€ν λ°©μ§: key κ²½λ‘λ stage-keysλ‘ λ¦¬λ€μ΄λ νΈ
|
| 2559 |
+
@main_bp.route('/admin/webtoons/key')
|
| 2560 |
+
@admin_required
|
| 2561 |
+
def admin_webtoon_key_redirect():
|
| 2562 |
+
return redirect(url_for('main.admin_webtoon_stage_keys'))
|
| 2563 |
+
|
| 2564 |
+
|
| 2565 |
@main_bp.route('/api/webtoons/project-uploads', methods=['GET'])
|
| 2566 |
@admin_required
|
| 2567 |
def list_webtoon_project_uploads():
|
|
|
|
| 4137 |
return jsonify({'error': f'νμ°¨λ³ μ μλ¨κ³ ν΅κ³ μ‘°ν μ€ μ€λ₯: {str(e)}'}), 500
|
| 4138 |
|
| 4139 |
|
| 4140 |
+
@main_bp.route('/api/webtoons/stage-keys', methods=['GET'])
|
| 4141 |
+
@admin_required
|
| 4142 |
+
def list_webtoon_stage_keys():
|
| 4143 |
+
"""λ¨κ³ν€ νμλͺ
λ§€ν λͺ©λ‘"""
|
| 4144 |
+
try:
|
| 4145 |
+
rows = WebtoonStageKeyMapping.query.order_by(WebtoonStageKeyMapping.stage_key.asc()).all()
|
| 4146 |
+
return jsonify({'mappings': [r.to_dict() for r in rows]}), 200
|
| 4147 |
+
except Exception as e:
|
| 4148 |
+
return jsonify({'error': f'λ¨κ³ν€ λ§€ν μ‘°ν μ€ μ€λ₯: {str(e)}'}), 500
|
| 4149 |
+
|
| 4150 |
+
|
| 4151 |
+
@main_bp.route('/api/webtoons/stage-keys', methods=['PUT'])
|
| 4152 |
+
@admin_required
|
| 4153 |
+
def upsert_webtoon_stage_keys():
|
| 4154 |
+
"""λ¨κ³ν€ νμλͺ
λ§€ν λ²ν¬ μ μ₯(μ
μνΈ)"""
|
| 4155 |
+
try:
|
| 4156 |
+
payload = request.get_json(silent=True) or {}
|
| 4157 |
+
items = payload.get('mappings') or []
|
| 4158 |
+
if not isinstance(items, list):
|
| 4159 |
+
return jsonify({'error': 'mappingsλ λ°°μ΄μ΄μ΄μΌ ν©λλ€.'}), 400
|
| 4160 |
+
|
| 4161 |
+
cleaned = []
|
| 4162 |
+
for it in items:
|
| 4163 |
+
if not isinstance(it, dict):
|
| 4164 |
+
continue
|
| 4165 |
+
k = (str(it.get('stage_key') or '').strip())
|
| 4166 |
+
v = (str(it.get('label') or '').strip())
|
| 4167 |
+
if not k:
|
| 4168 |
+
continue
|
| 4169 |
+
cleaned.append((k, v))
|
| 4170 |
+
|
| 4171 |
+
# λΉ labelμ μμ λ‘ μ·¨κΈ
|
| 4172 |
+
keys_to_delete = [k for k, v in cleaned if not v]
|
| 4173 |
+
if keys_to_delete:
|
| 4174 |
+
WebtoonStageKeyMapping.query.filter(WebtoonStageKeyMapping.stage_key.in_(keys_to_delete)).delete(synchronize_session=False)
|
| 4175 |
+
|
| 4176 |
+
# upsert
|
| 4177 |
+
for k, v in cleaned:
|
| 4178 |
+
if not v:
|
| 4179 |
+
continue
|
| 4180 |
+
row = WebtoonStageKeyMapping.query.filter_by(stage_key=k).first()
|
| 4181 |
+
if row:
|
| 4182 |
+
row.label = v
|
| 4183 |
+
else:
|
| 4184 |
+
db.session.add(WebtoonStageKeyMapping(stage_key=k, label=v))
|
| 4185 |
+
|
| 4186 |
+
db.session.commit()
|
| 4187 |
+
rows = WebtoonStageKeyMapping.query.order_by(WebtoonStageKeyMapping.stage_key.asc()).all()
|
| 4188 |
+
return jsonify({'message': 'μ μ₯λμμ΅λλ€.', 'mappings': [r.to_dict() for r in rows]}), 200
|
| 4189 |
+
except Exception as e:
|
| 4190 |
+
db.session.rollback()
|
| 4191 |
+
return jsonify({'error': f'λ¨κ³ν€ λ§€ν μ μ₯ μ€ μ€λ₯: {str(e)}'}), 500
|
| 4192 |
+
|
| 4193 |
+
|
| 4194 |
+
@main_bp.route('/api/webtoons/stage-keys/<path:stage_key>', methods=['DELETE'])
|
| 4195 |
+
@admin_required
|
| 4196 |
+
def delete_webtoon_stage_key(stage_key):
|
| 4197 |
+
"""λ¨κ³ν€ λ§€ν μμ """
|
| 4198 |
+
try:
|
| 4199 |
+
k = (str(stage_key or '').strip())
|
| 4200 |
+
if not k:
|
| 4201 |
+
return jsonify({'error': 'stage_keyκ° νμν©λλ€.'}), 400
|
| 4202 |
+
WebtoonStageKeyMapping.query.filter_by(stage_key=k).delete(synchronize_session=False)
|
| 4203 |
+
db.session.commit()
|
| 4204 |
+
return jsonify({'message': 'μμ λμμ΅λλ€.'}), 200
|
| 4205 |
+
except Exception as e:
|
| 4206 |
+
db.session.rollback()
|
| 4207 |
+
return jsonify({'error': f'λ¨κ³ν€ λ§€ν μμ μ€ μ€λ₯: {str(e)}'}), 500
|
| 4208 |
+
|
| 4209 |
+
|
| 4210 |
@main_bp.route('/api/webtoons/duration/stage-plan', methods=['POST'])
|
| 4211 |
@admin_required
|
| 4212 |
def plan_webtoon_duration_by_stage():
|
templates/admin_webtoon_planner.html
CHANGED
|
@@ -229,6 +229,8 @@
|
|
| 229 |
const startDateEl = document.getElementById('startDate');
|
| 230 |
const launchDateEl = document.getElementById('launchDate');
|
| 231 |
|
|
|
|
|
|
|
| 232 |
function showAlert(message, type) {
|
| 233 |
alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
|
| 234 |
setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
|
|
@@ -246,6 +248,28 @@
|
|
| 246 |
return div.innerHTML;
|
| 247 |
}
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
async function loadStats() {
|
| 250 |
const { res, data } = await fetchJson('/api/webtoons/duration/stats');
|
| 251 |
if (!res.ok) {
|
|
@@ -286,9 +310,10 @@
|
|
| 286 |
const title = (r.top_texts && r.top_texts[0]) ? r.top_texts[0].value : '';
|
| 287 |
const tasks = (r.top_tasks || []).slice(0, 5).map(x => x.value).join(', ');
|
| 288 |
const meta = [title, tasks].filter(Boolean).join(' / ');
|
|
|
|
| 289 |
return `
|
| 290 |
<tr>
|
| 291 |
-
<td><strong
|
| 292 |
<td>${r.count ?? 0}</td>
|
| 293 |
<td>${r.recommended_days ?? '-'}</td>
|
| 294 |
<td>${r.median_days ?? '-'}</td>
|
|
@@ -327,7 +352,7 @@
|
|
| 327 |
const header = `${e.episode_num}ν Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}μΌ)`;
|
| 328 |
const rows = stages.map(s => `
|
| 329 |
<tr>
|
| 330 |
-
<td style="width:90px;"><strong
|
| 331 |
<td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
|
| 332 |
<td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
|
| 333 |
<td style="width:90px;">${s.duration_days || 0}</td>
|
|
@@ -339,7 +364,7 @@
|
|
| 339 |
<div style="margin-top:10px;">
|
| 340 |
<table>
|
| 341 |
<thead>
|
| 342 |
-
<tr><th style="width:90px;"
|
| 343 |
</thead>
|
| 344 |
<tbody>${rows}</tbody>
|
| 345 |
</table>
|
|
@@ -389,8 +414,13 @@
|
|
| 389 |
showAlert('μμ°μ΄ μλ£λμμ΅λλ€.', 'success');
|
| 390 |
}
|
| 391 |
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
</script>
|
| 395 |
</body>
|
| 396 |
</html>
|
|
|
|
| 229 |
const startDateEl = document.getElementById('startDate');
|
| 230 |
const launchDateEl = document.getElementById('launchDate');
|
| 231 |
|
| 232 |
+
let stageKeyMap = {};
|
| 233 |
+
|
| 234 |
function showAlert(message, type) {
|
| 235 |
alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
|
| 236 |
setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
|
|
|
|
| 248 |
return div.innerHTML;
|
| 249 |
}
|
| 250 |
|
| 251 |
+
async function loadStageKeyMap() {
|
| 252 |
+
const { res, data } = await fetchJson('/api/webtoons/stage-keys');
|
| 253 |
+
if (!res.ok) {
|
| 254 |
+
stageKeyMap = {};
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
const items = data?.mappings || [];
|
| 258 |
+
stageKeyMap = {};
|
| 259 |
+
for (const it of items) {
|
| 260 |
+
const k = String(it?.stage_key || '').trim();
|
| 261 |
+
const v = String(it?.label || '').trim();
|
| 262 |
+
if (k && v) stageKeyMap[k] = v;
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function displayStageLabel(stageKey, fallbackText = '') {
|
| 267 |
+
const k = String(stageKey || '').trim();
|
| 268 |
+
const mapped = stageKeyMap[k];
|
| 269 |
+
if (mapped) return mapped;
|
| 270 |
+
return k || (fallbackText ? String(fallbackText) : '');
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
async function loadStats() {
|
| 274 |
const { res, data } = await fetchJson('/api/webtoons/duration/stats');
|
| 275 |
if (!res.ok) {
|
|
|
|
| 310 |
const title = (r.top_texts && r.top_texts[0]) ? r.top_texts[0].value : '';
|
| 311 |
const tasks = (r.top_tasks || []).slice(0, 5).map(x => x.value).join(', ');
|
| 312 |
const meta = [title, tasks].filter(Boolean).join(' / ');
|
| 313 |
+
const label = displayStageLabel(r.stage_key, title);
|
| 314 |
return `
|
| 315 |
<tr>
|
| 316 |
+
<td><strong title="${escapeHtml(r.stage_key || '')}">${escapeHtml(label || '')}</strong></td>
|
| 317 |
<td>${r.count ?? 0}</td>
|
| 318 |
<td>${r.recommended_days ?? '-'}</td>
|
| 319 |
<td>${r.median_days ?? '-'}</td>
|
|
|
|
| 352 |
const header = `${e.episode_num}ν Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}μΌ)`;
|
| 353 |
const rows = stages.map(s => `
|
| 354 |
<tr>
|
| 355 |
+
<td style="width:90px;"><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key, ''))}</strong></td>
|
| 356 |
<td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
|
| 357 |
<td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
|
| 358 |
<td style="width:90px;">${s.duration_days || 0}</td>
|
|
|
|
| 364 |
<div style="margin-top:10px;">
|
| 365 |
<table>
|
| 366 |
<thead>
|
| 367 |
+
<tr><th style="width:90px;">λ¨κ³</th><th style="width:130px;">μμ</th><th style="width:130px;">μ’
λ£</th><th style="width:90px;">μΌμ</th></tr>
|
| 368 |
</thead>
|
| 369 |
<tbody>${rows}</tbody>
|
| 370 |
</table>
|
|
|
|
| 414 |
showAlert('μμ°μ΄ μλ£λμμ΅λλ€.', 'success');
|
| 415 |
}
|
| 416 |
|
| 417 |
+
async function init() {
|
| 418 |
+
await loadStageKeyMap();
|
| 419 |
+
await loadStats();
|
| 420 |
+
basisEl.addEventListener('change', () => loadStats());
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
init();
|
| 424 |
</script>
|
| 425 |
</body>
|
| 426 |
</html>
|
templates/admin_webtoon_stage_keys.html
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="ko">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>λ¨κ³ν€ κ΄λ¦¬ - SOY NV AI</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
:root{
|
| 11 |
+
--bg:#f6f7fb;--card:#fff;--text:#202124;--muted:#5f6368;--border:#e5e7eb;--border-strong:#dadce0;
|
| 12 |
+
--primary:#1a73e8;--primary-hover:#1557b0;--shadow:0 10px 24px rgba(17,24,39,.08);--radius:12px;
|
| 13 |
+
--danger:#c5221f;
|
| 14 |
+
}
|
| 15 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 16 |
+
body{
|
| 17 |
+
font-family:'Inter', -apple-system, BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
| 18 |
+
background: radial-gradient(1200px 600px at 20% -10%, rgba(26,115,232,0.10), rgba(255,255,255,0)) , var(--bg);
|
| 19 |
+
color:var(--text);overflow-x:hidden
|
| 20 |
+
}
|
| 21 |
+
.container{max-width:1180px;margin:22px auto;padding:0 24px}
|
| 22 |
+
.page-header{margin:8px 0 14px}
|
| 23 |
+
.page-header h1{font-size:18px;font-weight:700;letter-spacing:-.2px}
|
| 24 |
+
.page-header p{margin-top:6px;color:var(--muted);font-size:13px;line-height:1.6}
|
| 25 |
+
|
| 26 |
+
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow);margin-bottom:14px}
|
| 27 |
+
.card-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;background:linear-gradient(to bottom,#fff,#fbfbff)}
|
| 28 |
+
.card-title{font-size:15px;font-weight:700}
|
| 29 |
+
.card-body{padding:16px}
|
| 30 |
+
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
| 31 |
+
.btn{padding:10px 12px;border:1px solid var(--border);border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;background:#fff;color:var(--text)}
|
| 32 |
+
.btn-primary{background:var(--primary);border-color:var(--primary);color:#fff}
|
| 33 |
+
.btn-primary:hover{background:var(--primary-hover);border-color:var(--primary-hover)}
|
| 34 |
+
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}
|
| 35 |
+
.btn-danger:hover{filter:brightness(.95)}
|
| 36 |
+
.btn:disabled{opacity:.55;cursor:not-allowed}
|
| 37 |
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:12px}
|
| 38 |
+
.alert{margin-bottom:12px;padding:10px 12px;border-radius:10px;font-size:13px;border:1px solid transparent}
|
| 39 |
+
.alert.success{background:#e6f4ea;border-color:#b7dfc0;color:#137333}
|
| 40 |
+
.alert.error{background:#fce8e6;border-color:#f6aea9;color:#c5221f}
|
| 41 |
+
.hint{color:var(--muted);font-size:12px;line-height:1.5}
|
| 42 |
+
|
| 43 |
+
table{width:100%;border-collapse:collapse}
|
| 44 |
+
thead th{text-align:left;font-size:12px;color:var(--muted);padding:10px 8px;border-bottom:1px solid var(--border);background:#fbfbff}
|
| 45 |
+
tbody td{font-size:13px;padding:10px 8px;border-bottom:1px solid var(--border);vertical-align:middle}
|
| 46 |
+
tbody tr:hover{background:#fafbff}
|
| 47 |
+
.input{
|
| 48 |
+
width:100%;
|
| 49 |
+
padding:10px 12px;border:1px solid var(--border-strong);border-radius:10px;font-size:13px;outline:none;background:#fff;
|
| 50 |
+
}
|
| 51 |
+
.input[readonly]{background:#f7f7fb;color:var(--muted)}
|
| 52 |
+
</style>
|
| 53 |
+
</head>
|
| 54 |
+
<body>
|
| 55 |
+
{% set admin_nav_title = 'μΉν°' %}
|
| 56 |
+
{% set admin_nav_icon = 'ποΈ' %}
|
| 57 |
+
{% include '_admin_nav.html' %}
|
| 58 |
+
|
| 59 |
+
<div class="container">
|
| 60 |
+
<div class="page-header">
|
| 61 |
+
<h1>λ¨κ³ν€ κ΄λ¦¬</h1>
|
| 62 |
+
<p>μμ
λ¨κ³ ν€(μ: <span class="mono">02</span>, <span class="mono">03-2</span>)λ₯Ό νμμ© λ¨κ³λͺ
(μ: <span class="mono">02. μ½ν°</span>)μΌλ‘ λ§€νν©λλ€. μ΄ μ€μ μ <strong>μ μ κΈ°κ° ν΅κ³/μ°μ </strong>, <strong>μ μ λ¨κ³ ν΅κ³/μ°μ </strong> λ± λ¨κ³ν€ νμ νλ©΄μ μ μ©λ©λλ€.</p>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div id="alertContainer"></div>
|
| 66 |
+
|
| 67 |
+
<div class="card">
|
| 68 |
+
<div class="card-header">
|
| 69 |
+
<div class="card-title">λ§€ν λͺ©λ‘</div>
|
| 70 |
+
<div class="row">
|
| 71 |
+
<button class="btn" type="button" onclick="reload()">μλ‘κ³ μΉ¨</button>
|
| 72 |
+
<button class="btn" type="button" onclick="addRow()">μΆκ°</button>
|
| 73 |
+
<button id="saveBtn" class="btn btn-primary" type="button" onclick="saveAll()">μ μ₯</button>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
<div class="card-body">
|
| 77 |
+
<table>
|
| 78 |
+
<thead>
|
| 79 |
+
<tr>
|
| 80 |
+
<th style="width:200px;">λ¨κ³ν€</th>
|
| 81 |
+
<th>νμλͺ
</th>
|
| 82 |
+
<th style="width:160px;">μ
λ°μ΄νΈ</th>
|
| 83 |
+
<th style="width:110px;">μμ </th>
|
| 84 |
+
</tr>
|
| 85 |
+
</thead>
|
| 86 |
+
<tbody id="tbody">
|
| 87 |
+
<tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">λΆλ¬μ€λ μ€...</td></tr>
|
| 88 |
+
</tbody>
|
| 89 |
+
</table>
|
| 90 |
+
<div class="hint" style="margin-top:10px;">
|
| 91 |
+
- <strong>μ μ₯</strong>μ μ 체 μ
μνΈ(μΆκ°/μμ )μ
λλ€.<br>
|
| 92 |
+
- νμλͺ
μ λΉμ°κ³ μ μ₯νλ©΄ ν΄λΉ λ¨κ³ν€ λ§€νμ μμ λ©λλ€.
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<script>
|
| 99 |
+
const alertContainer = document.getElementById('alertContainer');
|
| 100 |
+
const tbody = document.getElementById('tbody');
|
| 101 |
+
const saveBtn = document.getElementById('saveBtn');
|
| 102 |
+
|
| 103 |
+
function showAlert(message, type) {
|
| 104 |
+
alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
|
| 105 |
+
setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
|
| 106 |
+
}
|
| 107 |
+
async function fetchJson(url, options = {}) {
|
| 108 |
+
const res = await fetch(url, { credentials: 'include', ...options });
|
| 109 |
+
const text = await res.text();
|
| 110 |
+
let data = null;
|
| 111 |
+
try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
|
| 112 |
+
return { res, data };
|
| 113 |
+
}
|
| 114 |
+
function escapeHtml(text) {
|
| 115 |
+
const div = document.createElement('div');
|
| 116 |
+
div.textContent = text == null ? '' : String(text);
|
| 117 |
+
return div.innerHTML;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function rowTemplate(item, { isNew } = { isNew: false }) {
|
| 121 |
+
const k = item?.stage_key || '';
|
| 122 |
+
const v = item?.label || '';
|
| 123 |
+
const updatedAt = item?.updated_at || '';
|
| 124 |
+
return `
|
| 125 |
+
<tr data-new="${isNew ? '1' : '0'}" data-key="${escapeHtml(k)}">
|
| 126 |
+
<td>
|
| 127 |
+
<input class="input mono js-key" ${isNew ? '' : 'readonly'} placeholder="μ: 02 λλ 03-2" value="${escapeHtml(k)}" />
|
| 128 |
+
</td>
|
| 129 |
+
<td>
|
| 130 |
+
<input class="input js-label" placeholder="μ: 02. μ½ν°" value="${escapeHtml(v)}" />
|
| 131 |
+
</td>
|
| 132 |
+
<td class="mono" style="color:var(--muted)">${escapeHtml(updatedAt)}</td>
|
| 133 |
+
<td>
|
| 134 |
+
<button class="btn btn-danger" type="button" onclick="deleteRow(this)">μμ </button>
|
| 135 |
+
</td>
|
| 136 |
+
</tr>
|
| 137 |
+
`;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
async function reload() {
|
| 141 |
+
const { res, data } = await fetchJson('/api/webtoons/stage-keys');
|
| 142 |
+
if (!res.ok) {
|
| 143 |
+
showAlert(data?.error || `μ‘°ν μ€ν¨ (${res.status})`, 'error');
|
| 144 |
+
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">μ‘°ν μ€ν¨</td></tr>`;
|
| 145 |
+
return;
|
| 146 |
+
}
|
| 147 |
+
const items = data?.mappings || [];
|
| 148 |
+
if (!items.length) {
|
| 149 |
+
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--muted); padding: 18px;">λ§€νμ΄ μμ΅λλ€. βμΆκ°βλ‘ λ±λ‘νμΈμ.</td></tr>`;
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
tbody.innerHTML = items.map(it => rowTemplate(it, { isNew: false })).join('');
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function addRow() {
|
| 156 |
+
const html = rowTemplate({ stage_key: '', label: '' }, { isNew: true });
|
| 157 |
+
if (tbody.querySelector('tr td[colspan]')) {
|
| 158 |
+
tbody.innerHTML = html;
|
| 159 |
+
} else {
|
| 160 |
+
tbody.insertAdjacentHTML('beforeend', html);
|
| 161 |
+
}
|
| 162 |
+
// μ νμ key μ
λ ₯μ ν¬μ»€μ€
|
| 163 |
+
const last = tbody.querySelector('tr:last-child .js-key');
|
| 164 |
+
if (last) last.focus();
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function collectMappings() {
|
| 168 |
+
const rows = Array.from(tbody.querySelectorAll('tr')).filter(tr => !tr.querySelector('td[colspan]'));
|
| 169 |
+
const out = [];
|
| 170 |
+
for (const tr of rows) {
|
| 171 |
+
const k = (tr.querySelector('.js-key')?.value || '').trim();
|
| 172 |
+
const v = (tr.querySelector('.js-label')?.value || '').trim();
|
| 173 |
+
if (!k) continue;
|
| 174 |
+
out.push({ stage_key: k, label: v });
|
| 175 |
+
}
|
| 176 |
+
return out;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
async function saveAll() {
|
| 180 |
+
const mappings = collectMappings();
|
| 181 |
+
saveBtn.disabled = true;
|
| 182 |
+
const { res, data } = await fetchJson('/api/webtoons/stage-keys', {
|
| 183 |
+
method: 'PUT',
|
| 184 |
+
headers: { 'Content-Type': 'application/json' },
|
| 185 |
+
body: JSON.stringify({ mappings })
|
| 186 |
+
});
|
| 187 |
+
saveBtn.disabled = false;
|
| 188 |
+
if (!res.ok) {
|
| 189 |
+
showAlert(data?.error || `μ μ₯ μ€ν¨ (${res.status})`, 'error');
|
| 190 |
+
return;
|
| 191 |
+
}
|
| 192 |
+
showAlert('μ μ₯λμμ΅λλ€.', 'success');
|
| 193 |
+
await reload();
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
async function deleteRow(btn) {
|
| 197 |
+
const tr = btn.closest('tr');
|
| 198 |
+
const isNew = (tr?.getAttribute('data-new') === '1');
|
| 199 |
+
const key = (tr?.querySelector('.js-key')?.value || '').trim();
|
| 200 |
+
if (isNew) {
|
| 201 |
+
tr.remove();
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
if (!key) {
|
| 205 |
+
tr.remove();
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
if (!confirm(`'${key}' λ§€νμ μμ ν κΉμ?`)) return;
|
| 209 |
+
const { res, data } = await fetchJson(`/api/webtoons/stage-keys/${encodeURIComponent(key)}`, { method: 'DELETE' });
|
| 210 |
+
if (!res.ok) {
|
| 211 |
+
showAlert(data?.error || `μμ μ€ν¨ (${res.status})`, 'error');
|
| 212 |
+
return;
|
| 213 |
+
}
|
| 214 |
+
showAlert('μμ λμμ΅λλ€.', 'success');
|
| 215 |
+
await reload();
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
reload();
|
| 219 |
+
</script>
|
| 220 |
+
</body>
|
| 221 |
+
</html>
|
| 222 |
+
|
| 223 |
+
|
templates/admin_webtoon_stage_planner.html
CHANGED
|
@@ -205,6 +205,8 @@
|
|
| 205 |
const epStageSummary = document.getElementById('epStageSummary');
|
| 206 |
const stagePlanWrap = document.getElementById('stagePlanWrap');
|
| 207 |
|
|
|
|
|
|
|
| 208 |
function showAlert(message, type) {
|
| 209 |
alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
|
| 210 |
setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
|
|
@@ -222,6 +224,26 @@
|
|
| 222 |
return div.innerHTML;
|
| 223 |
}
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
let cachedEpStats = null;
|
| 226 |
|
| 227 |
async function loadEpStageStats() {
|
|
@@ -261,7 +283,7 @@
|
|
| 261 |
}
|
| 262 |
epStageTbody.innerHTML = stages.map(s => `
|
| 263 |
<tr>
|
| 264 |
-
<td><strong>${escapeHtml(s.stage_key || '')}</strong></td>
|
| 265 |
<td>${s.count ?? 0}</td>
|
| 266 |
<td>${s.recommended_days ?? '-'}</td>
|
| 267 |
<td>${s.median_days ?? '-'}</td>
|
|
@@ -296,7 +318,7 @@
|
|
| 296 |
const header = `${e.episode_num}ν Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}μΌ)`;
|
| 297 |
const rows = (e.stages || []).map(s => `
|
| 298 |
<tr>
|
| 299 |
-
<td style="width:90px;"><strong>${escapeHtml(s.stage_key || '')}</strong></td>
|
| 300 |
<td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
|
| 301 |
<td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
|
| 302 |
<td style="width:90px;">${s.duration_days || 0}</td>
|
|
@@ -308,7 +330,7 @@
|
|
| 308 |
<div style="margin-top:10px;">
|
| 309 |
<table>
|
| 310 |
<thead>
|
| 311 |
-
<tr><th style="width:90px;"
|
| 312 |
</thead>
|
| 313 |
<tbody>${rows}</tbody>
|
| 314 |
</table>
|
|
@@ -384,12 +406,12 @@
|
|
| 384 |
<div style="margin-top:10px;">
|
| 385 |
<table>
|
| 386 |
<thead>
|
| 387 |
-
<tr><th style="width:90px;"
|
| 388 |
</thead>
|
| 389 |
<tbody>
|
| 390 |
${g.map(s => `
|
| 391 |
<tr>
|
| 392 |
-
<td><strong>${escapeHtml(s.stage_key || '')}</strong></td>
|
| 393 |
<td class="mono">${escapeHtml(s.start_date || '')}</td>
|
| 394 |
<td class="mono">${escapeHtml(s.end_date || '')}</td>
|
| 395 |
<td>${s.duration_days || 0}</td>
|
|
@@ -430,9 +452,14 @@
|
|
| 430 |
showAlert('μΌμ μ§ν μμ°μ΄ μλ£λμμ΅λλ€.', 'success');
|
| 431 |
}
|
| 432 |
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
</script>
|
| 437 |
</body>
|
| 438 |
</html>
|
|
|
|
| 205 |
const epStageSummary = document.getElementById('epStageSummary');
|
| 206 |
const stagePlanWrap = document.getElementById('stagePlanWrap');
|
| 207 |
|
| 208 |
+
let stageKeyMap = {};
|
| 209 |
+
|
| 210 |
function showAlert(message, type) {
|
| 211 |
alertContainer.innerHTML = `<div class="alert ${type}">${message}</div>`;
|
| 212 |
setTimeout(() => { alertContainer.innerHTML = ''; }, 6000);
|
|
|
|
| 224 |
return div.innerHTML;
|
| 225 |
}
|
| 226 |
|
| 227 |
+
async function loadStageKeyMap() {
|
| 228 |
+
const { res, data } = await fetchJson('/api/webtoons/stage-keys');
|
| 229 |
+
if (!res.ok) {
|
| 230 |
+
stageKeyMap = {};
|
| 231 |
+
return;
|
| 232 |
+
}
|
| 233 |
+
const items = data?.mappings || [];
|
| 234 |
+
stageKeyMap = {};
|
| 235 |
+
for (const it of items) {
|
| 236 |
+
const k = String(it?.stage_key || '').trim();
|
| 237 |
+
const v = String(it?.label || '').trim();
|
| 238 |
+
if (k && v) stageKeyMap[k] = v;
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function displayStageLabel(stageKey) {
|
| 243 |
+
const k = String(stageKey || '').trim();
|
| 244 |
+
return stageKeyMap[k] || k;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
let cachedEpStats = null;
|
| 248 |
|
| 249 |
async function loadEpStageStats() {
|
|
|
|
| 283 |
}
|
| 284 |
epStageTbody.innerHTML = stages.map(s => `
|
| 285 |
<tr>
|
| 286 |
+
<td><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key || ''))}</strong></td>
|
| 287 |
<td>${s.count ?? 0}</td>
|
| 288 |
<td>${s.recommended_days ?? '-'}</td>
|
| 289 |
<td>${s.median_days ?? '-'}</td>
|
|
|
|
| 318 |
const header = `${e.episode_num}ν Β· ${e.start_date} ~ ${e.end_date} (${e.duration_days}μΌ)`;
|
| 319 |
const rows = (e.stages || []).map(s => `
|
| 320 |
<tr>
|
| 321 |
+
<td style="width:90px;"><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key || ''))}</strong></td>
|
| 322 |
<td style="width:130px;" class="mono">${escapeHtml(s.start_date || '')}</td>
|
| 323 |
<td style="width:130px;" class="mono">${escapeHtml(s.end_date || '')}</td>
|
| 324 |
<td style="width:90px;">${s.duration_days || 0}</td>
|
|
|
|
| 330 |
<div style="margin-top:10px;">
|
| 331 |
<table>
|
| 332 |
<thead>
|
| 333 |
+
<tr><th style="width:90px;">λ¨κ³</th><th style="width:130px;">μμ</th><th style="width:130px;">μ’
λ£</th><th style="width:90px;">μΌμ</th></tr>
|
| 334 |
</thead>
|
| 335 |
<tbody>${rows}</tbody>
|
| 336 |
</table>
|
|
|
|
| 406 |
<div style="margin-top:10px;">
|
| 407 |
<table>
|
| 408 |
<thead>
|
| 409 |
+
<tr><th style="width:90px;">λ¨κ³</th><th style="width:130px;">μμ</th><th style="width:130px;">μ’
λ£</th><th style="width:90px;">μΌμ</th></tr>
|
| 410 |
</thead>
|
| 411 |
<tbody>
|
| 412 |
${g.map(s => `
|
| 413 |
<tr>
|
| 414 |
+
<td><strong title="${escapeHtml(s.stage_key || '')}">${escapeHtml(displayStageLabel(s.stage_key || ''))}</strong></td>
|
| 415 |
<td class="mono">${escapeHtml(s.start_date || '')}</td>
|
| 416 |
<td class="mono">${escapeHtml(s.end_date || '')}</td>
|
| 417 |
<td>${s.duration_days || 0}</td>
|
|
|
|
| 452 |
showAlert('μΌμ μ§ν μμ°μ΄ μλ£λμμ΅λλ€.', 'success');
|
| 453 |
}
|
| 454 |
|
| 455 |
+
async function init() {
|
| 456 |
+
await loadStageKeyMap();
|
| 457 |
+
episodeSelect.addEventListener('change', () => renderEpStageTable(parseInt(episodeSelect.value, 10) || 1));
|
| 458 |
+
basisEl.addEventListener('change', () => loadAll());
|
| 459 |
+
await loadAll();
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
init();
|
| 463 |
</script>
|
| 464 |
</body>
|
| 465 |
</html>
|