Added SEO keywords. Added medua upload indicator. Added Khabarovsk default
Build and Publish / Build and Publish Docker Image (push) Successful in 1m17s
Build and Publish / Build and Publish Docker Image (push) Successful in 1m17s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "web-petting"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+5
-1
@@ -796,6 +796,7 @@ struct SettingsForm {
|
||||
pricing_info: String,
|
||||
timezone: String,
|
||||
site_domain: String,
|
||||
seo_keywords: String,
|
||||
}
|
||||
|
||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||
@@ -811,6 +812,7 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
||||
("pricing_info", form.pricing_info),
|
||||
("timezone", form.timezone),
|
||||
("site_domain", form.site_domain),
|
||||
("seo_keywords", form.seo_keywords),
|
||||
] {
|
||||
let k = key.to_string();
|
||||
let existing = query!(Setting, $key == k).get(&db).await?;
|
||||
@@ -1172,7 +1174,7 @@ async fn schedule_events(
|
||||
.unwrap_or("?");
|
||||
|
||||
let (bg_color, text_color) = match v.status.as_str() {
|
||||
"cancelled" => ("#ccc".to_string(), "#666"),
|
||||
"cancelled" => ("#ffb3b3".to_string(), "#a00"),
|
||||
"completed" => (format!("{}88", client_color), "#fff"),
|
||||
_ => (client_color.to_string(), "#fff"),
|
||||
};
|
||||
@@ -1641,8 +1643,10 @@ async fn media_delete(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
||||
let file_path = m.file_path.clone();
|
||||
m.status = "archived".to_string();
|
||||
m.save(&db).await?;
|
||||
let _ = tokio::fs::remove_file(&file_path).await;
|
||||
}
|
||||
let redirect_url = referer
|
||||
.filter(|r| r.contains("/schedule/") && r.contains("/edit"))
|
||||
|
||||
+4
-1
@@ -134,6 +134,7 @@ pub struct Translations {
|
||||
pub settings_pricing_info: &'static str,
|
||||
pub settings_timezone: &'static str,
|
||||
pub settings_site_domain: &'static str,
|
||||
pub settings_seo_keywords: &'static str,
|
||||
pub landing_contact_label: &'static str,
|
||||
pub landing_pricing_title: &'static str,
|
||||
|
||||
@@ -346,6 +347,7 @@ static RU: Translations = Translations {
|
||||
settings_pricing_info: "Блок с ценами (отображается на лендинге)",
|
||||
settings_timezone: "Часовой пояс (например Asia/Vladivostok)",
|
||||
settings_site_domain: "Домен сайта (например https://example.com)",
|
||||
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
|
||||
landing_contact_label: "Или свяжитесь с нами напрямую",
|
||||
landing_pricing_title: "Стоимость",
|
||||
|
||||
@@ -425,7 +427,7 @@ static RU: Translations = Translations {
|
||||
schedule_delete_confirm: "Точно удалить этот визит?",
|
||||
|
||||
landing_meta_description: "Профессиональный пет-ситтинг: кормление и уход за кошками, грызунами, рептилиями на вашей территории. Оставьте заявку — позаботимся о вашем любимце!",
|
||||
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома",
|
||||
landing_hero_title: "Позаботимся о вашем питомце, пока вас нет дома. Город Хабаровск",
|
||||
landing_hero_subtitle: "Кормление и уход за кошками, грызунами, рептилиями на вашей территории. Ежедневные визиты — ваш питомец в надёжных руках, пока вы в отпуске или командировке.",
|
||||
landing_hero_description: "Почему лучше оставить кошку дома на время отъезда, чем, скажем, поместить в зоогостиницу? Как известно — кошка территориальное животное. Поэтому, когда кошка оказывается на незнакомой территории — она может испытывать стресс. К тому же в зоогостинице животное часто содержится в клетке. А кошки любят свободу. И дома ожидать своих хозяев — ей будет гораздо проще и комфортнее.",
|
||||
landing_hero_cta: "Оставить заявку",
|
||||
@@ -548,6 +550,7 @@ static EN: Translations = Translations {
|
||||
settings_pricing_info: "Pricing block (shown on landing page)",
|
||||
settings_timezone: "Timezone (e.g. Asia/Vladivostok)",
|
||||
settings_site_domain: "Site domain (e.g. https://example.com)",
|
||||
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
|
||||
landing_contact_label: "Or contact us directly",
|
||||
landing_pricing_title: "Pricing",
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ struct LandingTemplate<'a> {
|
||||
lang: Lang,
|
||||
contact_info: String,
|
||||
pricing_info: String,
|
||||
seo_keywords: String,
|
||||
testimonials: Vec<Testimonial>,
|
||||
site_domain: String,
|
||||
review_count: usize,
|
||||
@@ -103,6 +104,12 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_else(|| "https://example.net".to_string());
|
||||
let seo_key = "seo_keywords".to_string();
|
||||
let seo_keywords = query!(Setting, $key == seo_key)
|
||||
.get(&db)
|
||||
.await?
|
||||
.map(|s| s.value)
|
||||
.unwrap_or_default();
|
||||
let mut testimonials = Testimonial::objects().all(&db).await?;
|
||||
testimonials.retain(|t| t.status == "active");
|
||||
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
||||
@@ -112,6 +119,7 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
|
||||
lang,
|
||||
contact_info,
|
||||
pricing_info,
|
||||
seo_keywords,
|
||||
testimonials,
|
||||
site_domain,
|
||||
review_count,
|
||||
|
||||
@@ -33,12 +33,6 @@
|
||||
</div>
|
||||
{% if lead.status == "new" || lead.status == "in_progress" %}
|
||||
<div class="item-card-actions">
|
||||
{% if lead.status == "new" %}
|
||||
<form method="post" action="/admin/leads/{{ lead.id }}/status">
|
||||
<input type="hidden" name="status" value="in_progress">
|
||||
<button type="submit" class="button is-small is-info is-outlined btn-sm">{{ t.action_in_progress }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/leads/{{ lead.id }}/convert">
|
||||
<button type="submit" class="button is-small is-success is-outlined btn-sm">{{ t.action_convert }}</button>
|
||||
</form>
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
<div><strong>{{ t.schedule_date }}:</strong> {{ visit_label }}</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
||||
<form id="uploadForm" action="/admin/media/{{ visit_id }}/upload/submit" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.media_choose_files }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -29,7 +30,80 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
<!-- Progress -->
|
||||
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var form = document.getElementById('uploadForm');
|
||||
var filesInput = document.getElementById('uploadFiles');
|
||||
var fileCount = document.getElementById('fileCount');
|
||||
var progress = document.getElementById('uploadProgress');
|
||||
var bar = document.getElementById('uploadBar');
|
||||
var percent = document.getElementById('uploadPercent');
|
||||
var statusText = document.getElementById('uploadStatusText');
|
||||
var submitBtn = document.getElementById('uploadSubmit');
|
||||
|
||||
filesInput.addEventListener('change', function() {
|
||||
var n = this.files.length;
|
||||
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
if (!filesInput.files.length) return;
|
||||
|
||||
var data = new FormData(form);
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
progress.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Загрузка...';
|
||||
bar.style.width = '0%';
|
||||
percent.textContent = '0%';
|
||||
|
||||
xhr.upload.addEventListener('progress', function(ev) {
|
||||
if (!ev.lengthComputable) return;
|
||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||
bar.style.width = pct + '%';
|
||||
percent.textContent = pct + '%';
|
||||
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', function() {
|
||||
if (xhr.status >= 200 && xhr.status < 400) {
|
||||
bar.style.width = '100%';
|
||||
percent.textContent = '100%';
|
||||
statusText.textContent = 'Готово!';
|
||||
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
|
||||
} else {
|
||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', function() {
|
||||
statusText.textContent = 'Ошибка соединения';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
});
|
||||
|
||||
xhr.open('POST', form.action);
|
||||
xhr.send(data);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
.fc .fc-toolbar-title { font-size: 1.1rem !important; }
|
||||
.fc .fc-button { padding: 0.25rem 0.5rem !important; font-size: 0.8rem !important; }
|
||||
.fc-event { cursor: pointer; border: none !important; padding: 2px 5px; border-radius: 4px; }
|
||||
.fc-event.ev-completed .fc-event-title,
|
||||
.fc-event.ev-completed .fc-list-event-title { text-decoration: line-through; opacity: 0.75; }
|
||||
.fc-event.ev-cancelled .fc-event-title,
|
||||
.fc-event.ev-cancelled .fc-list-event-title { text-decoration: line-through; }
|
||||
.fc .fc-day-today { background: #eef2ff !important; }
|
||||
.fc .fc-day.day-weekend { background: #faf5f0; }
|
||||
.fc .fc-day-today.day-weekend { background: #eef2ff !important; }
|
||||
@@ -45,8 +49,6 @@
|
||||
.visit-modal { background:#fff; border-radius:12px; padding:1.5rem; width:90%; max-width:380px; box-shadow:0 4px 24px rgba(0,0,0,0.15); }
|
||||
.visit-modal h3 { margin:0 0 0.75rem; font-size:1.1rem; }
|
||||
.visit-modal .meta { color:#888; font-size:0.85rem; margin-bottom:0.75rem; line-height:1.6; }
|
||||
.visit-modal .actions { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
||||
.visit-modal .actions form { margin:0; }
|
||||
.color-dot { display:inline-block; width:12px; height:12px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
||||
</style>
|
||||
|
||||
@@ -60,12 +62,8 @@
|
||||
<div id="vmTime"></div>
|
||||
<div id="vmNotes" style="margin-top:0.3rem;"></div>
|
||||
</div>
|
||||
<div id="vmStatus" style="margin-bottom:0.75rem;"></div>
|
||||
<div class="actions" id="vmActions"></div>
|
||||
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
|
||||
<a id="vmEditLink" href="#" class="button is-info is-small" style="flex:1;">{{ t.schedule_edit_title }}</a>
|
||||
<button class="button is-light is-small" style="flex:1;" onclick="closeModal()">OK</button>
|
||||
</div>
|
||||
<div id="vmStatus" style="margin-bottom:1rem;"></div>
|
||||
<a id="vmEditLink" href="#" class="button is-primary is-fullwidth" style="font-size:1rem;font-weight:700;padding:0.65rem;">📋 {{ t.schedule_edit_title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,6 +87,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
|
||||
},
|
||||
events: '/admin/schedule/events',
|
||||
eventDidMount: function(info) {
|
||||
var status = info.event.extendedProps.status;
|
||||
if (status === 'completed') info.el.classList.add('ev-completed');
|
||||
if (status === 'cancelled') info.el.classList.add('ev-cancelled');
|
||||
},
|
||||
eventClick: function(info) {
|
||||
info.jsEvent.preventDefault();
|
||||
const ev = info.event;
|
||||
@@ -102,12 +105,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('vmNotes').textContent = p.notes || '';
|
||||
const badge = '<span class="badge badge-visit-' + p.status + '">' + statusLabels[p.status] + '</span>';
|
||||
document.getElementById('vmStatus').innerHTML = badge;
|
||||
let actions = '';
|
||||
if (p.status === 'scheduled') {
|
||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/done"><button class="button is-small is-success is-outlined">{{ t.schedule_mark_done }}</button></form>';
|
||||
actions += '<form method="post" action="/admin/schedule/' + ev.id + '/cancel"><button class="button is-small is-danger is-outlined">{{ t.schedule_cancel }}</button></form>';
|
||||
}
|
||||
document.getElementById('vmActions').innerHTML = actions;
|
||||
document.getElementById('vmEditLink').href = '/admin/schedule/' + ev.id + '/edit?lang=' + lang;
|
||||
document.getElementById('visitModal').classList.add('is-open');
|
||||
},
|
||||
|
||||
@@ -70,14 +70,17 @@
|
||||
<!-- Status -->
|
||||
<div class="field">
|
||||
<label class="label">{{ t.schedule_status }}</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="status">
|
||||
<option value="scheduled" {% if visit.status == "scheduled" %}selected{% endif %}>{{ t.visit_status_scheduled }}</option>
|
||||
<option value="completed" {% if visit.status == "completed" %}selected{% endif %}>{{ t.visit_status_completed }}</option>
|
||||
<option value="cancelled" {% if visit.status == "cancelled" %}selected{% endif %}>{{ t.visit_status_cancelled }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="status" id="statusInput" value="{{ visit.status }}">
|
||||
<div class="status-picker">
|
||||
<button type="button" class="status-btn status-btn-scheduled {% if visit.status == "scheduled" %}is-active{% endif %}" data-value="scheduled">
|
||||
<span class="status-btn-icon">📅</span>{{ t.visit_status_scheduled }}
|
||||
</button>
|
||||
<button type="button" class="status-btn status-btn-completed {% if visit.status == "completed" %}is-active{% endif %}" data-value="completed">
|
||||
<span class="status-btn-icon">✅</span>{{ t.visit_status_completed }}
|
||||
</button>
|
||||
<button type="button" class="status-btn status-btn-cancelled {% if visit.status == "cancelled" %}is-active{% endif %}" data-value="cancelled">
|
||||
<span class="status-btn-icon">✕</span>{{ t.visit_status_cancelled }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,14 +153,15 @@
|
||||
<div class="upload-modal">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<h3 style="font-size:1.1rem;font-weight:700;margin:0;">{{ t.media_upload_title }}</h3>
|
||||
<button type="button" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;" onclick="document.getElementById('uploadModal').classList.remove('is-open')">✕</button>
|
||||
<button type="button" id="uploadModalClose" style="background:none;border:none;font-size:1.2rem;cursor:pointer;color:#888;">✕</button>
|
||||
</div>
|
||||
<form method="post" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||
<form id="uploadForm" action="/admin/media/{{ visit.id }}/upload/submit" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<label class="label">{{ t.media_choose_files }}</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="files" multiple accept="image/*,video/*" required>
|
||||
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
|
||||
</div>
|
||||
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.media_caption }}</label>
|
||||
@@ -165,12 +169,56 @@
|
||||
<input class="input" type="text" name="caption" placeholder="{{ t.media_caption }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="uploadProgress" style="display:none;margin-bottom:1rem;">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.82rem;color:#555;margin-bottom:0.3rem;">
|
||||
<span id="uploadStatusText">{{ t.media_upload }}...</span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div style="background:#e8e8e8;border-radius:99px;height:8px;overflow:hidden;">
|
||||
<div id="uploadBar" style="height:100%;width:0%;background:linear-gradient(90deg,#6c63ff,#b06cff);border-radius:99px;transition:width 0.2s;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="uploadSubmit" class="button is-primary is-fullwidth">{{ t.media_upload }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-picker {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.status-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.6rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
border: 2px solid transparent;
|
||||
background: #f5f5f5;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: #777;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.status-btn-icon { font-size: 1.3rem; line-height: 1; }
|
||||
.status-btn:hover { filter: brightness(0.95); }
|
||||
|
||||
.status-btn-scheduled.is-active { background: #dbeafe; border-color: #3b82f6; color: #1e40af; }
|
||||
.status-btn-completed.is-active { background: #d1fae5; border-color: #22c55e; color: #15803d; }
|
||||
.status-btn-cancelled.is-active { background: #fee2e2; border-color: #ef4444; color: #b91c1c; }
|
||||
|
||||
.status-btn-scheduled:not(.is-active):hover { background: #eff6ff; color: #3b82f6; }
|
||||
.status-btn-completed:not(.is-active):hover { background: #f0fdf4; color: #22c55e; }
|
||||
.status-btn-cancelled:not(.is-active):hover { background: #fff5f5; color: #ef4444; }
|
||||
|
||||
.visit-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
@@ -229,8 +277,91 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.getElementById('uploadModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) this.classList.remove('is-open');
|
||||
// Status picker
|
||||
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.status-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||
btn.classList.add('is-active');
|
||||
document.getElementById('statusInput').value = btn.dataset.value;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var modal = document.getElementById('uploadModal');
|
||||
var form = document.getElementById('uploadForm');
|
||||
var filesInput = document.getElementById('uploadFiles');
|
||||
var fileCount = document.getElementById('fileCount');
|
||||
var progress = document.getElementById('uploadProgress');
|
||||
var bar = document.getElementById('uploadBar');
|
||||
var percent = document.getElementById('uploadPercent');
|
||||
var statusText = document.getElementById('uploadStatusText');
|
||||
var submitBtn = document.getElementById('uploadSubmit');
|
||||
|
||||
// Close modal on backdrop click
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal();
|
||||
});
|
||||
document.getElementById('uploadModalClose').addEventListener('click', closeModal);
|
||||
|
||||
function closeModal() {
|
||||
if (submitBtn.disabled) return; // prevent close during upload
|
||||
modal.classList.remove('is-open');
|
||||
}
|
||||
|
||||
// Show selected file count
|
||||
filesInput.addEventListener('change', function() {
|
||||
var n = this.files.length;
|
||||
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : '';
|
||||
});
|
||||
|
||||
// Submit via XHR for progress tracking
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
if (!filesInput.files.length) return;
|
||||
|
||||
var data = new FormData(form);
|
||||
var xhr = new XMLHttpRequest();
|
||||
|
||||
// Show progress bar, disable submit
|
||||
progress.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Загрузка...';
|
||||
bar.style.width = '0%';
|
||||
percent.textContent = '0%';
|
||||
|
||||
xhr.upload.addEventListener('progress', function(ev) {
|
||||
if (!ev.lengthComputable) return;
|
||||
var pct = Math.round(ev.loaded / ev.total * 100);
|
||||
bar.style.width = pct + '%';
|
||||
percent.textContent = pct + '%';
|
||||
if (pct === 100) statusText.textContent = 'Обработка...';
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', function() {
|
||||
if (xhr.status >= 200 && xhr.status < 400) {
|
||||
bar.style.width = '100%';
|
||||
percent.textContent = '100%';
|
||||
statusText.textContent = 'Готово!';
|
||||
// Reload page to show uploaded media
|
||||
setTimeout(function() { window.location.reload(); }, 300);
|
||||
} else {
|
||||
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', function() {
|
||||
statusText.textContent = 'Ошибка соединения';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = '{{ t.media_upload }}';
|
||||
});
|
||||
|
||||
xhr.open('POST', form.action);
|
||||
xhr.send(data);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -44,7 +44,64 @@
|
||||
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ t.settings_seo_keywords }}</label>
|
||||
<div class="control">
|
||||
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3"
|
||||
style="resize:vertical;"
|
||||
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
|
||||
</div>
|
||||
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
|
||||
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var COLORS = [
|
||||
'rgba(124,108,255,0.18)',
|
||||
'rgba(255,82,135,0.15)',
|
||||
'rgba(255,140,38,0.18)',
|
||||
'rgba(0,180,150,0.15)',
|
||||
'rgba(77,166,255,0.18)',
|
||||
'rgba(255,179,64,0.18)',
|
||||
'rgba(176,108,255,0.16)',
|
||||
'rgba(34,180,130,0.16)',
|
||||
];
|
||||
|
||||
var ta = document.getElementById('seoKeywordsInput');
|
||||
var preview = document.getElementById('seoPreview');
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function render() {
|
||||
var text = ta.value.trim();
|
||||
if (!text) { preview.style.display = 'none'; return; }
|
||||
|
||||
var parts = text.split(',');
|
||||
var html = '';
|
||||
parts.forEach(function(part, i) {
|
||||
var word = part.trim();
|
||||
if (word) {
|
||||
var color = COLORS[i % COLORS.length];
|
||||
html += '<span style="background:' + color + ';border-radius:4px;padding:2px 6px;margin:2px;">' + esc(word) + '</span>';
|
||||
}
|
||||
if (i < parts.length - 1) {
|
||||
html += '<span style="color:#ccc;font-size:0.8em;margin:0 1px">,</span>';
|
||||
}
|
||||
});
|
||||
|
||||
preview.innerHTML = html;
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
|
||||
ta.addEventListener('input', render);
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<meta property="og:locale" content="{% if lang.code() == "ru" %}ru_RU{% else %}en_US{% endif %}">
|
||||
<meta property="og:site_name" content="{{ t.nav_title }}">
|
||||
|
||||
{% if !seo_keywords.is_empty() %}
|
||||
<meta name="keywords" content="{{ seo_keywords }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{ t.nav_title }} — {{ t.landing_hero_title }}">
|
||||
@@ -301,6 +305,10 @@
|
||||
border-top: 1px solid rgba(180,170,220,0.15);
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.seo-keywords {
|
||||
font-size: 0.72rem; color: #aaa; line-height: 2;
|
||||
max-width: 800px; margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 600px) {
|
||||
@@ -469,6 +477,9 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="site-footer">
|
||||
{% if !seo_keywords.is_empty() %}
|
||||
<p class="seo-keywords" id="seoKeywords">{{ seo_keywords }}</p>
|
||||
{% endif %}
|
||||
<p>{{ t.landing_footer_text }}</p>
|
||||
<p style="margin-top:0.4rem;">© 2026 {{ t.nav_title }}. {{ t.landing_footer_copyright }}.</p>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user