@@ -0,0 +1,429 @@
#!/usr/bin/env python3
import os , tempfile , re
from flask import Flask , request , jsonify
import boto3
from botocore . client import Config
import pillow_heif
from PIL import Image
pillow_heif . register_heif_opener ( )
app = Flask ( __name__ )
app . config [ ' MAX_CONTENT_LENGTH ' ] = 100 * 1024 * 1024
S3_CONFIG = dict (
endpoint_url = ' https://s3.regru.cloud ' ,
aws_access_key_id = ' TXBN3MRO1YCC2Y593URD ' ,
aws_secret_access_key = ' GIMA9lJetvXgMtl8GCO4dNSpJDGHvKgGBVfiA9zV ' ,
region_name = ' ru-1 ' ,
config = Config ( signature_version = ' s3v4 ' )
)
BUCKET = ' sleeptrip-dev '
MIME = {
' jpg ' : ' image/jpeg ' , ' jpeg ' : ' image/jpeg ' , ' png ' : ' image/png ' ,
' gif ' : ' image/gif ' , ' webp ' : ' image/webp ' , ' mp4 ' : ' video/mp4 ' , ' mov ' : ' video/quicktime ' ,
' heic ' : ' image/jpeg ' , ' heif ' : ' image/jpeg '
}
ALLOWED = set ( MIME . keys ( ) )
PAGE = r """ <!DOCTYPE html>
<html lang= " ru " >
<head>
<meta charset= " UTF-8 " >
<meta name= " viewport " content= " width=device-width,initial-scale=1 " >
<title>PTP Post Wizard</title>
<style>
* { box-sizing:border-box;margin:0;padding:0}
body { background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont, ' Segoe UI ' ,sans-serif;padding:0 0 60px}
.top { background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px;display:flex;align-items:center;gap:12px}
.top h1 { font-size:15px;font-weight:600}
.top .sub { font-size:12px;color:#8b949e}
.wrap { max-width:860px;margin:0 auto;padding:24px}
.section { margin-bottom:24px}
.section-title { font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#8b949e;margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid #21262d}
.grid2 { display:grid;grid-template-columns:1fr 1fr;gap:12px}
.grid3 { display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
label { display:block;font-size:11px;color:#8b949e;margin-bottom:4px}
input,textarea { width:100 % ;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px 10px;color:#e6edf3;font-size:13px;font-family:inherit;outline:none;transition:border-color .15s}
input:focus,textarea:focus { border-color:#58a6ff}
.hint { font-size:11px;color:#8b949e;margin-top:4px}
.slug-preview { font-family:monospace;font-size:12px;color:#58a6ff;margin-top:4px}
.drop { border:2px dashed #30363d;border-radius:10px;padding:40px 24px;text-align:center;cursor:pointer;transition:all .2s;position:relative}
.drop.over,.drop.has-files { border-color:#58a6ff;background:rgba(88,166,255,.04)}
.drop-icon { font-size:32px;margin-bottom:10px;color:#8b949e}
.drop-text { font-size:13px;color:#8b949e}
.drop-text b { color:#58a6ff;cursor:pointer}
.drop input { display:none}
.photos { display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-top:16px}
.photo-card { background:#161b22;border:1px solid #30363d;border-radius:8px;overflow:hidden;position:relative}
.photo-card img { width:100 % ;height:110px;object-fit:cover;display:block}
.photo-card .ph-name { font-size:10px;font-family:monospace;padding:6px 8px;color:#58a6ff;word-break:break-all;line-height:1.4}
.photo-card .ph-num { position:absolute;top:6px;left:6px;background:rgba(0,0,0,.7);color:#fff;font-size:11px;font-weight:700;padding:2px 7px;border-radius:4px}
.photo-card .ph-del { position:absolute;top:6px;right:6px;background:rgba(248,81,73,.8);color:#fff;border:none;border-radius:4px;padding:2px 7px;font-size:11px;cursor:pointer}
.photo-card.uploading {opacity:.6}
.photo-card.uploading::after { content: ' ↑ ' ;position:absolute;top:50 % ;left:50 % ;transform:translate(-50 % ,-50 % );font-size:32px;color:#58a6ff}
.output { background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden}
.out-tabs { display:flex;border-bottom:1px solid #30363d}
.out-tab { padding:10px 18px;font-size:12px;color:#8b949e;cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}
.out-tab.active { color:#58a6ff;border-bottom-color:#58a6ff}
.out-panel { display:none;padding:16px}
.out-panel.active { display:block}
.out-panel pre { background:#0d1117;border-radius:6px;padding:14px;font-size:12px;font-family:monospace;overflow-x:auto;white-space:pre-wrap;word-break:break-all;color:#e6edf3;max-height:400px;overflow-y:auto}
.copy-btn { background:#21262d;border:1px solid #30363d;border-radius:6px;padding:6px 14px;font-size:12px;color:#8b949e;cursor:pointer;float:right;margin-bottom:8px;transition:all .15s}
.copy-btn:hover { color:#e6edf3;border-color:#8b949e}
.copy-btn.ok { color:#3fb950;border-color:#3fb950}
.err { color:#f85149;font-size:12px;padding:8px 12px;background:rgba(248,81,73,.08);border-radius:6px;margin-top:8px;display:none}
.prog-bar { height:3px;background:#21262d;border-radius:2px;margin-top:4px;display:none}
.prog-bar-fill { height:3px;background:#58a6ff;border-radius:2px;width:0;transition:width .1s}
.notice { background:rgba(88,166,255,.08);border:1px solid rgba(88,166,255,.2);border-radius:6px;padding:10px 14px;font-size:12px;color:#8b949e;margin-bottom:16px}
</style>
</head>
<body>
<div class= " top " >
<div>
<h1>PTP Post Wizard</h1>
<div class= " sub " >Загрузка фото → готовый пост для Sveltia CMS</div>
</div>
</div>
<div class= " wrap " >
<!-- STEP 1: INFO -->
<div class= " section " >
<div class= " section-title " >1. Информация о посте</div>
<div class= " grid2 " style= " margin-bottom:12px " >
<div>
<label>Заголовок</label>
<input type= " text " id= " title " placeholder= " Дюкинский заказник " oninput= " onTitleInput() " >
<div class= " hint " >Название места — как на сайте</div>
</div>
<div>
<label>Slug (URL страницы и база имён фото)</label>
<input type= " text " id= " slug " placeholder= " Dukyn2504 " oninput= " onSlugInput() " >
<div class= " slug-preview " id= " slug-preview " ></div>
</div>
</div>
<div class= " grid3 " >
<div>
<label>Дата поездки</label>
<input type= " date " id= " date " oninput= " updateOutput() " >
<div class= " hint " id= " date-fmt " ></div>
</div>
<div>
<label>Описание (анонс)</label>
<input type= " text " id= " desc " placeholder= " скалы, лес, отличная погода " oninput= " updateOutput() " >
</div>
<div>
<label>Disqus ID</label>
<input type= " number " id= " disqus " placeholder= " 134 " min= " 1 " oninput= " updateOutput() " >
<div class= " hint " >Следующий свободный: 134</div>
</div>
</div>
</div>
<!-- STEP 2: UPLOAD -->
<div class= " section " >
<div class= " section-title " >2. Фотографии</div>
<div class= " notice " >Фото автоматически называются <b id= " naming-hint " >slug-YYYYMMDD-N.jpg</b> и загружаются в S3. Первое фото = обложка.</div>
<div class= " drop " id= " drop " >
<input type= " file " id= " fi " accept= " image/*,video/* " multiple>
<div class= " drop-icon " >⬆</div>
<div class= " drop-text " >Перетащи фото сюда или <b onclick= " document.getElementById( ' fi ' ).click() " >выбери файлы</b></div>
</div>
<div class= " prog-bar " id= " prog " ><div class= " prog-bar-fill " id= " prog-fill " ></div></div>
<div class= " err " id= " err " ></div>
<div class= " photos " id= " photos " ></div>
</div>
<!-- STEP 3: OUTPUT -->
<div class= " section " id= " output-section " style= " display:none " >
<div class= " section-title " >3. Готовый контент — копируй в Sveltia</div>
<div class= " output " >
<div class= " out-tabs " >
<div class= " out-tab active " onclick= " switchTab(0) " >Поля формы</div>
<div class= " out-tab " onclick= " switchTab(1) " >Markdown контент</div>
<div class= " out-tab " onclick= " switchTab(2) " >Все URL фото</div>
</div>
<div class= " out-panel active " id= " tab0 " >
<button class= " copy-btn " onclick= " copyPanel( ' fields-out ' , ' this ' ) " >Копировать</button>
<pre id= " fields-out " ></pre>
</div>
<div class= " out-panel " id= " tab1 " >
<button class= " copy-btn " onclick= " copyPanel( ' md-out ' , ' this ' ) " >Копировать</button>
<pre id= " md-out " ></pre>
</div>
<div class= " out-panel " id= " tab2 " >
<button class= " copy-btn " onclick= " copyPanel( ' urls-out ' , ' this ' ) " >Копировать</button>
<pre id= " urls-out " ></pre>
</div>
</div>
</div>
</div>
<script>
// ---- State ----
var photos = []; // { url, filename, ext}
var counter = 0;
var uploading = 0;
// ---- Transliteration ----
var RU = { ' а ' : ' a ' , ' б ' : ' b ' , ' в ' : ' v ' , ' г ' : ' g ' , ' д ' : ' d ' , ' е ' : ' e ' , ' ё ' : ' yo ' , ' ж ' : ' zh ' , ' з ' : ' z ' , ' и ' : ' i ' , ' й ' : ' y ' , ' к ' : ' k ' , ' л ' : ' l ' , ' м ' : ' m ' , ' н ' : ' n ' , ' о ' : ' o ' , ' п ' : ' p ' , ' р ' : ' r ' , ' с ' : ' s ' , ' т ' : ' t ' , ' у ' : ' u ' , ' ф ' : ' f ' , ' х ' : ' kh ' , ' ц ' : ' ts ' , ' ч ' : ' ch ' , ' ш ' : ' sh ' , ' щ ' : ' sch ' , ' ъ ' : ' ' , ' ы ' : ' y ' , ' ь ' : ' ' , ' э ' : ' e ' , ' ю ' : ' yu ' , ' я ' : ' ya ' };
function translit(s) {
return s.toLowerCase().split( ' ' ).map(function(c) { return RU[c]||c;}).join( ' ' ).replace(/[^a-z0-9]+/g, ' - ' ).replace(/^-+|-+$/g, ' ' );
}
function onTitleInput() {
var slug = document.getElementById( ' slug ' );
if(!slug._manual) {
var t = translit(document.getElementById( ' title ' ).value);
slug.value = t;
}
onSlugInput();
}
function onSlugInput() {
var slug = document.getElementById( ' slug ' );
slug._manual = slug.value.length > 0;
updateNamingHint();
updateOutput();
}
function getSlug() { return document.getElementById( ' slug ' ).value.trim() || ' slug ' ; }
function getDateStr() {
var d = document.getElementById( ' date ' ).value;
return d ? d.replace(/-/g, ' ' ) : ' YYYYMMDD ' ;
}
function getDateISO() {
var d = document.getElementById( ' date ' ).value;
return d ? d + ' T00:00:00 ' : ' ' ;
}
function updateNamingHint() {
var s = getSlug(); var d = getDateStr();
document.getElementById( ' naming-hint ' ).textContent = s+ ' - ' +d+ ' -N.jpg ' ;
var p = document.getElementById( ' slug-preview ' );
p.textContent = s ? ' URL: /post/ ' +s+ ' / ' : ' ' ;
}
// init date = today
(function() {
var d = new Date();
var iso = d.toISOString().slice(0,10);
document.getElementById( ' date ' ).value = iso;
updateNamingHint();
})();
// ---- Drag & Drop ----
var drop = document.getElementById( ' drop ' );
var fi = document.getElementById( ' fi ' );
drop.addEventListener( ' dragover ' ,function(e) { e.preventDefault();drop.classList.add( ' over ' );});
drop.addEventListener( ' dragleave ' ,function() { drop.classList.remove( ' over ' );});
drop.addEventListener( ' drop ' ,function(e) {
e.preventDefault(); drop.classList.remove( ' over ' );
var files = e.dataTransfer.files;
for(var i=0;i<files.length;i++) uploadFile(files[i]);
});
fi.addEventListener( ' change ' ,function() {
for(var i=0;i<fi.files.length;i++) uploadFile(fi.files[i]);
fi.value= ' ' ;
});
function uploadFile(file) {
var slug = getSlug(); var dateStr = getDateStr();
counter++;
var n = counter;
var ext = file.name.split( ' . ' ).pop().toLowerCase();
var outExt = (ext=== ' heic ' ||ext=== ' heif ' ) ? ' jpg ' : ext;
var filename = slug+ ' - ' +dateStr+ ' - ' +n+ ' . ' +outExt;
// Placeholder card
var card = document.createElement( ' div ' );
card.className= ' photo-card uploading ' ;
card.id= ' card- ' +n;
card.innerHTML= ' <div style= " height:110px;background:#21262d " ></div><div class= " ph-name " > ' +filename+ ' </div><div class= " ph-num " ># ' +n+ ' </div> ' ;
document.getElementById( ' photos ' ).appendChild(card);
drop.classList.add( ' has-files ' );
var prog = document.getElementById( ' prog ' );
prog.style.display= ' block ' ;
uploading++;
var fd = new FormData();
fd.append( ' file ' , file);
fd.append( ' filename ' , filename);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e) {
if(e.lengthComputable) document.getElementById( ' prog-fill ' ).style.width=(e.loaded/e.total*100)+ ' % ' ;
};
xhr.onload = function() {
uploading--;
if(uploading===0) { prog.style.display= ' none ' ; document.getElementById( ' prog-fill ' ).style.width= ' 0 ' ;}
if(xhr.status===200) {
var r = JSON.parse(xhr.responseText);
photos.push( { url:r.url, filename:filename, ext:outExt, n:n});
photos.sort(function(a,b) { return a.n-b.n;});
// Update card
var c = document.getElementById( ' card- ' +n);
c.className= ' photo-card ' ;
c.innerHTML= ' <img src= " ' +r.url+ ' " onerror= " this.style.display= \ ' none \ ' " > '
+ ' <div class= " ph-num " ># ' +n+ ' </div> '
+ ' <button class= " ph-del " onclick= " deletePhoto( ' +n+ ' ) " >✕</button> '
+ ' <div class= " ph-name " > ' +filename+ ' </div> ' ;
updateOutput();
} else {
var msg= ' Ошибка ' ; try { msg=JSON.parse(xhr.responseText).error;}catch(e) {}
var c=document.getElementById( ' card- ' +n); if(c) c.remove();
showErr(msg); counter--;
}
};
xhr.onerror=function() { uploading--;showErr( ' Сетевая ошибка ' );};
xhr.open( ' POST ' , ' /upload/api ' );
xhr.withCredentials=true;
xhr.send(fd);
}
function deletePhoto(n) {
photos = photos.filter(function(p) { return p.n!==n;});
var c=document.getElementById( ' card- ' +n); if(c) c.remove();
if(photos.length===0) document.getElementById( ' output-section ' ).style.display= ' none ' ;
updateOutput();
}
function showErr(msg) { var e=document.getElementById( ' err ' );e.textContent=msg;e.style.display= ' block ' ;setTimeout(function() { e.style.display= ' none ' ;},5000);}
// ---- Output generation ----
function updateOutput() {
updateNamingHint();
if(photos.length===0) return;
document.getElementById( ' output-section ' ).style.display= ' block ' ;
var title = document.getElementById( ' title ' ).value.trim() || ' — ' ;
var slug = getSlug();
var date = getDateISO();
var desc = document.getElementById( ' desc ' ).value.trim() || ' — ' ;
var disqus = document.getElementById( ' disqus ' ).value.trim() || ' — ' ;
var cover = photos.length>0 ? photos[0].url : ' ' ;
// Tab 0: fields
var fields = ' Заголовок: ' +title+ ' \ n '
+ ' Slug: ' +slug+ ' \ n '
+ ' Дата: ' +(document.getElementById( ' date ' ).value|| ' — ' )+ ' \ n '
+ ' Описание: ' +desc+ ' \ n '
+ ' Обложка: ' +cover+ ' \ n '
+ ' Disqus ID: ' +disqus;
document.getElementById( ' fields-out ' ).textContent = fields;
// Tab 1: markdown content
var md = ' Текст поездки — напиши здесь. \ n \ n ' ;
photos.forEach(function(p) {
md += '  \ n \ n ' ;
});
md += ' {{ < youtube id= " YOUTUBE_ID " >}} \ n \ n ' ;
md += ' --- \ n \ n ' ;
md += ' 📍 Локация \ n ' ;
md += ' {{ < rawhtml >}} \ n ' ;
md += ' <div class= " yandex-map-container " > \ n ' ;
md += ' <!-- Вставь embed-код карты из Яндекс Конструктора --> \ n ' ;
md += ' </div> \ n ' ;
md += ' {{ < /rawhtml >}} \ n \ n ' ;
md += ' {{ < rawhtml >}} \ n {{ < back-to-top >}} \ n {{ < /rawhtml >}} ' ;
document.getElementById( ' md-out ' ).textContent = md;
// Tab 2: URLs
var urls = photos.map(function(p,i) { return ' # ' +(i+1)+ ' : ' +p.url;}).join( ' \ n ' );
document.getElementById( ' urls-out ' ).textContent = urls;
}
// ---- Tabs ----
function switchTab(i) {
var tabs=document.querySelectorAll( ' .out-tab ' );
var panels=document.querySelectorAll( ' .out-panel ' );
tabs.forEach(function(t,j) { t.className= ' out-tab ' +(j===i? ' active ' : ' ' );});
panels.forEach(function(p,j) { p.className= ' out-panel ' +(j===i? ' active ' : ' ' );});
}
// ---- Copy ----
function copyPanel(id, btn) {
var text = document.getElementById(id).textContent;
navigator.clipboard.writeText(text).then(function() {
var b = document.querySelector( ' [onclick*= " ' +id+ ' " ] ' );
var orig = b.textContent; b.textContent= ' ✓ Скопировано ' ; b.className= ' copy-btn ok ' ;
setTimeout(function() { b.textContent=orig;b.className= ' copy-btn ' ;},2000);
});
}
</script>
</body>
</html>
"""
@app.route ( ' /upload/ ' )
def index ( ) :
return PAGE
@app.route ( ' /upload/api ' , methods = [ ' POST ' ] )
def upload_file ( ) :
if ' file ' not in request . files :
return jsonify ( { ' error ' : ' Нет файла ' } ) , 400
f = request . files [ ' file ' ]
custom_filename = request . form . get ( ' filename ' , ' ' ) . strip ( )
if not f . filename :
return jsonify ( { ' error ' : ' Пустое имя файла ' } ) , 400
ext = f . filename . rsplit ( ' . ' , 1 ) [ - 1 ] . lower ( ) if ' . ' in f . filename else ' '
if ext not in ALLOWED :
return jsonify ( { ' error ' : ' Формат . {} не поддерживается ' . format ( ext ) } ) , 400
# Use custom filename from wizard if provided
if custom_filename :
out_name = custom_filename
out_ext = out_name . rsplit ( ' . ' , 1 ) [ - 1 ] . lower ( )
else :
original = os . path . splitext ( f . filename ) [ 0 ]
out_ext = ' jpg ' if ext in ( ' heic ' , ' heif ' ) else ext
out_name = ' {} . {} ' . format ( original , out_ext )
s3_key = ' images/ {} ' . format ( out_name )
tmp_path = None
try :
with tempfile . NamedTemporaryFile ( delete = False , suffix = ' . {} ' . format ( ext ) ) as tmp :
f . save ( tmp . name )
tmp_path = tmp . name
# HEIC → JPG conversion
if ext in ( ' heic ' , ' heif ' ) :
img = Image . open ( tmp_path )
converted = tmp_path . rsplit ( ' . ' , 1 ) [ 0 ] + ' .jpg '
img . convert ( ' RGB ' ) . save ( converted , ' JPEG ' , quality = 92 )
os . unlink ( tmp_path )
tmp_path = converted
import urllib3
urllib3 . disable_warnings ( )
s3 = boto3 . client ( ' s3 ' , verify = False , * * S3_CONFIG )
s3 . upload_file (
tmp_path , BUCKET , s3_key ,
ExtraArgs = {
' ContentType ' : MIME . get ( out_ext , ' application/octet-stream ' ) ,
' CacheControl ' : ' max-age=31536000 '
}
)
url = ' https://s3.regru.cloud/ {} / {} ' . format ( BUCKET , s3_key )
return jsonify ( { ' url ' : url , ' filename ' : out_name } )
except Exception as e :
return jsonify ( { ' error ' : str ( e ) } ) , 500
finally :
if tmp_path and os . path . exists ( tmp_path ) :
os . unlink ( tmp_path )
if __name__ == ' __main__ ' :
app . run ( host = ' 127.0.0.1 ' , port = 9001 , debug = False )