Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:45:30 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions
+103
View File
@@ -0,0 +1,103 @@
const API_BASE = "/api";
class ApiClient {
getToken() {
return localStorage.getItem("access_token");
}
async request(endpoint, options = {}) {
const url = `${API_BASE}${endpoint}`;
const token = this.getToken();
const headers = {
"Content-Type": "application/json",
...options.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
localStorage.removeItem("access_token");
localStorage.removeItem("user");
window.location.href = "/login";
throw new Error("Session expired");
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `Request failed: ${response.status}`);
}
if (response.status === 204) return null;
return response.json();
}
get(endpoint) {
return this.request(endpoint, { method: "GET" });
}
post(endpoint, data) {
return this.request(endpoint, {
method: "POST",
body: JSON.stringify(data),
});
}
put(endpoint, data) {
return this.request(endpoint, {
method: "PUT",
body: JSON.stringify(data),
});
}
patch(endpoint, data) {
return this.request(endpoint, {
method: "PATCH",
body: JSON.stringify(data),
});
}
delete(endpoint) {
return this.request(endpoint, { method: "DELETE" });
}
async upload(endpoint, file) {
const url = `${API_BASE}${endpoint}`;
const token = this.getToken();
const formData = new FormData();
formData.append("file", file);
const headers = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(url, {
method: "POST",
headers,
body: formData,
});
if (response.status === 401) {
localStorage.removeItem("access_token");
localStorage.removeItem("user");
window.location.href = "/login";
throw new Error("Session expired");
}
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `Upload failed: ${response.status}`);
}
return response.json();
}
}
const api = new ApiClient();
export default api;
+2
View File
@@ -0,0 +1,2 @@
// App-wide constants, enums, status maps
// TODO: implement
+169
View File
@@ -0,0 +1,169 @@
// Centralized date, time, and currency formatters for the entire app.
// All dates use Greek/European style (DD/MM/YY). Never use US-style (MM/DD/YY).
//
// Usage:
// import { fmtDate, fmtDateTime, fmtDateShort, fmtDateFull, toDatetimeLocal, nowLocal } from '@/lib/formatters'
// ─── Locale ──────────────────────────────────────────────────────────────────
// el-GR gives DD/MM/YYYY ordering. en-GB is acceptable too (same day-first).
// We use el-GR for short numeric dates and en-GB for human-readable dates
// because en-GB gives English month names with day-first ordering.
const LOCALE = 'el-GR'
const LOCALE_LONG = 'en-GB'
// ─── Pre-built Intl formatters (fast — created once) ─────────────────────────
// "05/03/2026" (DD/MM/YYYY)
const SHORT_DATE = new Intl.DateTimeFormat(LOCALE, {
day: '2-digit', month: '2-digit', year: 'numeric',
})
// "5 Mar 2026"
const MEDIUM_DATE = new Intl.DateTimeFormat(LOCALE_LONG, {
day: 'numeric', month: 'short', year: 'numeric',
})
// "5 March 2026"
const LONG_DATE = new Intl.DateTimeFormat(LOCALE_LONG, {
day: 'numeric', month: 'long', year: 'numeric',
})
// "Wednesday, 5 March 2026"
const FULL_DATE = new Intl.DateTimeFormat(LOCALE_LONG, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
})
// "14:30" (24h)
const TIME_24H = new Intl.DateTimeFormat(LOCALE_LONG, {
hour: '2-digit', minute: '2-digit', hour12: false,
})
// "2:30 pm" (12h)
const TIME_12H = new Intl.DateTimeFormat(LOCALE_LONG, {
hour: 'numeric', minute: '2-digit', hour12: true,
})
// "Wed, 5 Mar 2026, 2:30 pm"
const FULL_DATETIME = new Intl.DateTimeFormat(LOCALE_LONG, {
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: true,
})
// ─── Safe parse helper ───────────────────────────────────────────────────────
function safeDate(iso) {
if (!iso) return null
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? null : d
}
// ─── Exported formatters ─────────────────────────────────────────────────────
/** "05/03/2026" — short numeric, Greek style DD/MM/YYYY */
export function fmtDate(iso) {
const d = safeDate(iso)
return d ? SHORT_DATE.format(d) : '—'
}
/** "5 Mar 2026" — medium with abbreviated month */
export function fmtDateMedium(iso) {
const d = safeDate(iso)
return d ? MEDIUM_DATE.format(d) : '—'
}
/** "5 March 2026" — long with full month name */
export function fmtDateLong(iso) {
const d = safeDate(iso)
return d ? LONG_DATE.format(d) : '—'
}
/** "Wednesday, 5 March 2026" — full with weekday */
export function fmtDateFull(iso) {
const d = safeDate(iso)
return d ? FULL_DATE.format(d) : '—'
}
/** "5 March 2026, 2:30 pm" — date + time (12h) */
export function fmtDateTime(iso) {
const d = safeDate(iso)
if (!d) return '—'
return `${LONG_DATE.format(d)}, ${TIME_12H.format(d).toLowerCase()}`
}
/** "5 Mar 2026, 14:30" — date + time (24h) */
export function fmtDateTimeMedium(iso) {
const d = safeDate(iso)
if (!d) return '—'
return `${MEDIUM_DATE.format(d)}, ${TIME_24H.format(d)}`
}
/** "Wed, 5 Mar 2026, 2:30 pm" — full datetime for emails/comms */
export function fmtDateTimeFull(iso) {
const d = safeDate(iso)
return d ? FULL_DATETIME.format(d) : ''
}
/** "14:30:05" — time with seconds, 24h */
export function fmtTime24(iso) {
const d = safeDate(iso)
if (!d) return '—'
return d.toLocaleTimeString(LOCALE_LONG, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
/** Relative time: "just now", "5 minutes ago", "3 days ago", etc. */
export function fmtRelative(iso) {
const d = safeDate(iso)
if (!d) return ''
const seconds = Math.floor((Date.now() - d.getTime()) / 1000)
if (seconds < 60) return 'just now'
const m = Math.floor(seconds / 60)
if (m < 60) return `${m} minute${m !== 1 ? 's' : ''} ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h} hour${h !== 1 ? 's' : ''} ago`
const days = Math.floor(h / 24)
if (days < 7) return `${days} day${days !== 1 ? 's' : ''} ago`
const w = Math.floor(days / 7)
if (w < 5) return `${w} week${w !== 1 ? 's' : ''} ago`
const mo = Math.floor(days / 30)
if (mo < 12) return `${mo} month${mo !== 1 ? 's' : ''} ago`
const y = Math.floor(days / 365)
return `${y} year${y !== 1 ? 's' : ''} ago`
}
// ─── Form input helpers ──────────────────────────────────────────────────────
/**
* Convert an ISO string to a `datetime-local` input value in LOCAL time.
* Avoids the UTC-shift bug that toISOString().slice(0,16) causes.
*/
export function toDatetimeLocal(iso) {
if (!iso) return ''
const d = safeDate(iso)
if (!d) return ''
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Current local time as a `datetime-local` input value string. */
export function nowLocal() {
const d = new Date()
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/** Convert a `datetime-local` value to ISO date string for date-only inputs. */
export function toDateInput(iso) {
if (!iso) return ''
const d = safeDate(iso)
if (!d) return ''
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
// ─── Currency ────────────────────────────────────────────────────────────────
/** Format number as euros: "1.250,00 €" (Greek locale) */
export function fmtEuro(n) {
const f = parseFloat(n) || 0
return f.toLocaleString('el-GR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
}
+29
View File
@@ -0,0 +1,29 @@
// frontend/src/lib/loadQuill.js
// Shared Quill CDN loader — ensures the script is only injected once across
// all modals (ComposeEmailModal, MailSettingsModal, etc.).
// Calls cb() immediately if Quill is already on window, otherwise queues it.
let _ready = false
let _callbacks = []
export function loadQuill(cb) {
if (_ready || window.Quill) { _ready = true; cb(); return }
_callbacks.push(cb)
if (document.getElementById('__quill_js__')) return // already injecting
const link = document.createElement('link')
link.id = '__quill_css__'
link.rel = 'stylesheet'
link.href = 'https://cdn.quilljs.com/1.3.7/quill.snow.css'
document.head.appendChild(link)
const script = document.createElement('script')
script.id = '__quill_js__'
script.src = 'https://cdn.quilljs.com/1.3.7/quill.min.js'
script.onload = () => {
_ready = true
_callbacks.forEach(fn => fn())
_callbacks = []
}
document.head.appendChild(script)
}
+81
View File
@@ -0,0 +1,81 @@
// frontend/src/lib/melodyUtils.js
// Pure utility functions for melody pages. Ported from _archive/melodies/melodyUtils.js.
export function formatDuration(seconds) {
if (seconds === 0) return 'Single Run'
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return `${min}:${sec.toString().padStart(2, '0')}`
}
export function parseLocalizedString(value) {
if (!value) return {}
if (typeof value === 'object') return value
try {
const parsed = JSON.parse(value)
if (typeof parsed === 'object' && parsed !== null) return parsed
} catch {
return { en: value }
}
return {}
}
export function serializeLocalizedString(dict) {
if (!dict || typeof dict !== 'object') return ''
return JSON.stringify(dict)
}
export function getLocalizedValue(value, lang, fallback = '') {
const dict = parseLocalizedString(value)
return dict[lang] || dict['en'] || Object.values(dict)[0] || fallback
}
export function normalizeColor(val) {
if (!val) return '#888888'
if (val.startsWith('0x') || val.startsWith('0X')) {
const hex = val.slice(2)
return `#${hex.length === 8 ? hex.slice(2) : hex}`
}
if (val.startsWith('#')) return val
return `#${val}`
}
export function getLanguageName(code) {
return LANGUAGE_MASTER_LIST.find((l) => l.code === code)?.name ?? code.toUpperCase()
}
export const LANGUAGE_MASTER_LIST = [
{ code: 'en', name: 'English' },
{ code: 'el', name: 'Greek' },
{ code: 'sr', name: 'Serbian' },
{ code: 'bg', name: 'Bulgarian' },
{ code: 'ro', name: 'Romanian' },
{ code: 'ru', name: 'Russian' },
{ code: 'uk', name: 'Ukrainian' },
{ code: 'ka', name: 'Georgian' },
{ code: 'ar', name: 'Arabic' },
{ code: 'de', name: 'German' },
{ code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' },
{ code: 'it', name: 'Italian' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'nl', name: 'Dutch' },
{ code: 'pl', name: 'Polish' },
{ code: 'cs', name: 'Czech' },
{ code: 'sk', name: 'Slovak' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'hr', name: 'Croatian' },
{ code: 'sl', name: 'Slovenian' },
{ code: 'mk', name: 'Macedonian' },
{ code: 'sq', name: 'Albanian' },
{ code: 'tr', name: 'Turkish' },
{ code: 'ja', name: 'Japanese' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ko', name: 'Korean' },
{ code: 'hi', name: 'Hindi' },
{ code: 'he', name: 'Hebrew' },
{ code: 'fi', name: 'Finnish' },
{ code: 'sv', name: 'Swedish' },
{ code: 'da', name: 'Danish' },
{ code: 'no', name: 'Norwegian' },
]