Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
// App-wide constants, enums, status maps
|
||||
// TODO: implement
|
||||
@@ -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 }) + ' €'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
Reference in New Issue
Block a user