- fallbacks composants + directus.ts passes a Mostuki Photo / Cannes - corrige Contact.astro (PARIS -> CANNES) - seed/token scripts : defauts Mostuki + email corentin.jog@gmail.com - docker-compose dev : conteneurs mostuki-*, email admin, secrets dev - backup.sh : conteneurs par defaut mostuki-* - package.json : name mostuki-photo + description vitrine - .env.example : email admin - DEPLOY.md : retrait des references obsoletes a l ancien branding Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
353 lines
14 KiB
JavaScript
353 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Seed Directus : crée les 2 collections (site_settings singleton + series),
|
|
* leurs fields, les permissions publiques en lecture, et pre-remplit
|
|
* le singleton avec les valeurs par défaut du site.
|
|
*
|
|
* Idempotent : skip ce qui existe déjà.
|
|
*
|
|
* Usage :
|
|
* node scripts/seed-directus.mjs
|
|
*
|
|
* Vars d'env attendues (default = valeurs du docker-compose) :
|
|
* DIRECTUS_PUBLIC_URL (default http://localhost:8055)
|
|
* DIRECTUS_ADMIN_EMAIL
|
|
* DIRECTUS_ADMIN_PASSWORD
|
|
*/
|
|
|
|
import 'node:process';
|
|
|
|
const URL_BASE = process.env.DIRECTUS_PUBLIC_URL || 'http://localhost:8055';
|
|
const EMAIL = process.env.DIRECTUS_ADMIN_EMAIL || 'corentin.jog@gmail.com';
|
|
const PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD || 'changeme-please';
|
|
|
|
let TOKEN = null;
|
|
|
|
async function api(method, path, body) {
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
|
|
const res = await fetch(`${URL_BASE}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
let json;
|
|
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
|
if (!res.ok) {
|
|
const isAlreadyExists = res.status === 400 && JSON.stringify(json).includes('already exists');
|
|
if (isAlreadyExists) return { skipped: true, json };
|
|
throw new Error(`${method} ${path} → ${res.status}\n${JSON.stringify(json, null, 2)}`);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
async function login() {
|
|
const r = await api('POST', '/auth/login', { email: EMAIL, password: PASSWORD });
|
|
TOKEN = r.data.access_token;
|
|
console.log('✓ logged in as', EMAIL);
|
|
}
|
|
|
|
async function ensureCollection(collection, meta = {}, schema = {}) {
|
|
try {
|
|
await api('GET', `/collections/${collection}`);
|
|
console.log(`= collection ${collection} already exists`);
|
|
return false;
|
|
} catch {
|
|
await api('POST', '/collections', { collection, meta, schema });
|
|
console.log(`✓ collection ${collection} created`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async function ensureField(collection, field, type, meta = {}, schema = {}) {
|
|
try {
|
|
await api('GET', `/fields/${collection}/${field}`);
|
|
return false;
|
|
} catch {
|
|
const body = { field, type, meta: { interface: 'input', ...meta }, schema };
|
|
await api('POST', `/fields/${collection}`, body);
|
|
console.log(` + ${collection}.${field}`);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
async function ensureRelation(collection, field, related_collection) {
|
|
// Sans cet appel, l'UI Directus admin affiche le champ file vide même quand
|
|
// un UUID est stocké en DB (l'API REST POST /fields ne crée pas la foreign key
|
|
// par défaut). Idempotent : skip si la relation existe déjà.
|
|
try {
|
|
const existing = await api('GET', `/relations/${collection}/${field}`);
|
|
if (existing?.data) {
|
|
return false;
|
|
}
|
|
} catch {
|
|
// 404 → relation n'existe pas, on continue
|
|
}
|
|
await api('POST', '/relations', { collection, field, related_collection });
|
|
console.log(` ↪ relation ${collection}.${field} → ${related_collection}`);
|
|
return true;
|
|
}
|
|
|
|
async function ensureM2MFiles(collection, field) {
|
|
// Setup d'un champ "gallery" M2M files (Directus 11 ne le fait pas auto via REST).
|
|
// Crée la collection junction, ses 2 champs M2O, le champ alias, et les 2 relations.
|
|
// Idempotent : skip ce qui existe déjà.
|
|
const junction = `${collection}_files`;
|
|
const fkOne = `${collection}_id`;
|
|
const fkMany = 'directus_files_id';
|
|
|
|
// 1. Collection junction
|
|
try {
|
|
await api('GET', `/collections/${junction}`);
|
|
} catch {
|
|
await api('POST', '/collections', {
|
|
collection: junction,
|
|
meta: { hidden: true, icon: 'import_export' },
|
|
schema: {},
|
|
fields: [{
|
|
field: 'id',
|
|
type: 'integer',
|
|
meta: { hidden: true, interface: 'input', readonly: true },
|
|
schema: { is_primary_key: true, has_auto_increment: true },
|
|
}],
|
|
});
|
|
console.log(` + collection ${junction}`);
|
|
}
|
|
|
|
// 2. Champs M2O dans la junction
|
|
await ensureField(junction, fkOne, 'integer', { interface: 'select-dropdown-m2o', hidden: true });
|
|
await ensureField(junction, fkMany, 'uuid', { interface: 'file', hidden: true });
|
|
|
|
// 3. Champ alias dans la collection parente
|
|
try {
|
|
await api('GET', `/fields/${collection}/${field}`);
|
|
} catch {
|
|
await api('POST', `/fields/${collection}`, {
|
|
field,
|
|
type: 'alias',
|
|
meta: {
|
|
interface: 'files',
|
|
special: ['files'],
|
|
options: { enableCreate: true, enableSelect: true },
|
|
},
|
|
schema: null,
|
|
});
|
|
console.log(` + ${collection}.${field} (alias files)`);
|
|
}
|
|
|
|
// 4. Relations M2M (idempotent par try/catch sur déjà existant)
|
|
try {
|
|
await api('GET', `/relations/${junction}/${fkMany}`);
|
|
} catch {
|
|
await api('POST', '/relations', {
|
|
collection: junction,
|
|
field: fkMany,
|
|
related_collection: 'directus_files',
|
|
meta: { junction_field: fkOne },
|
|
});
|
|
console.log(` ↪ relation ${junction}.${fkMany} → directus_files`);
|
|
}
|
|
try {
|
|
await api('GET', `/relations/${junction}/${fkOne}`);
|
|
} catch {
|
|
await api('POST', '/relations', {
|
|
collection: junction,
|
|
field: fkOne,
|
|
related_collection: collection,
|
|
meta: { one_field: field, junction_field: fkMany },
|
|
});
|
|
console.log(` ↪ relation ${junction}.${fkOne} → ${collection} (M2M lié à .${field})`);
|
|
}
|
|
}
|
|
|
|
async function ensurePublicRead(collection) {
|
|
// Directus 11 : permissions sont attachees a des policies. La policy "Public"
|
|
// est creee par defaut. On essaie de la trouver et de lui attacher read.
|
|
try {
|
|
const policies = await api('GET', `/policies?filter[name][_eq]=Public&limit=1`);
|
|
const publicPolicy = policies?.data?.[0];
|
|
if (!publicPolicy) {
|
|
console.log(` ! pas de policy Public trouvee, skip ${collection} (a faire via UI)`);
|
|
return false;
|
|
}
|
|
await api('POST', '/permissions', {
|
|
policy: publicPolicy.id,
|
|
collection,
|
|
action: 'read',
|
|
fields: ['*'],
|
|
});
|
|
console.log(` ✓ public read on ${collection}`);
|
|
return true;
|
|
} catch (e) {
|
|
if (String(e.message).includes('already exists') || String(e.message).includes('RECORD_NOT_UNIQUE')) {
|
|
return false;
|
|
}
|
|
console.log(` ! permission ${collection} : ${e.message.split('\n')[0]} (a faire via UI Settings -> Access Policies -> Public)`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function setSingleton(collection, values) {
|
|
// Seed seulement les champs vides — on n'écrase pas les modifs déjà saisies.
|
|
// Le seed sert à pré-remplir un singleton fraichement créé, pas à le reset.
|
|
let current = {};
|
|
try {
|
|
const r = await api('GET', `/items/${collection}`);
|
|
current = r?.data || {};
|
|
} catch {
|
|
// singleton vide / inexistant
|
|
}
|
|
const toSet = {};
|
|
for (const [k, v] of Object.entries(values)) {
|
|
const cur = current[k];
|
|
const isEmpty = cur === null || cur === undefined || cur === '' ||
|
|
(Array.isArray(cur) && cur.length === 0);
|
|
if (isEmpty) toSet[k] = v;
|
|
}
|
|
if (Object.keys(toSet).length === 0) {
|
|
console.log(`= ${collection} déjà rempli, skip defaults`);
|
|
return;
|
|
}
|
|
await api('PATCH', `/items/${collection}`, toSet);
|
|
console.log(`✓ defaults set on ${collection} (${Object.keys(toSet).length} champ(s) vides remplis)`);
|
|
}
|
|
|
|
(async () => {
|
|
console.log(`Seeding Directus at ${URL_BASE}\n`);
|
|
await login();
|
|
|
|
// ============ site_settings (singleton) ============
|
|
console.log('\n=== site_settings (singleton) ===');
|
|
await ensureCollection('site_settings', {
|
|
icon: 'tune',
|
|
singleton: true,
|
|
note: 'Contenu marketing du site (hero, manifesto, contact, etc.). Une seule ligne.',
|
|
});
|
|
|
|
const settingsFields = [
|
|
['studio_name', 'string', { width: 'half' }],
|
|
['city', 'string', { width: 'half' }],
|
|
['region', 'string', { width: 'half' }],
|
|
['country', 'string', { width: 'half' }],
|
|
['coords', 'string', { width: 'half', note: '"43.55N 7.02E"' }],
|
|
['email', 'string', { width: 'half', interface: 'input', validation: { _and: [{ email: { _regex: '^[^@]+@[^@]+\\.[^@]+$' } }] } }],
|
|
['est_year', 'integer', { width: 'half' }],
|
|
['current_year', 'integer', { width: 'half' }],
|
|
// hero
|
|
['hero_tag', 'string'],
|
|
['hero_display_lines', 'json', { interface: 'tags', note: 'Lignes display du hero, ex: ["STUDIO PHOTO.", "CANNES / PACA."]' }],
|
|
['hero_italic_line', 'string'],
|
|
['hero_lede', 'text'],
|
|
['hero_stat', 'string', { note: 'Signature courte hero, ex: "ON OBSERVE / ON ENREGISTRE / ON DERANGE PEU"' }],
|
|
['hero_year_prefix', 'string', { width: 'half' }],
|
|
['hero_year_suffix', 'string', { width: 'half' }],
|
|
// manifesto
|
|
['manifesto_tag', 'string'],
|
|
['manifesto_italic', 'string'],
|
|
['manifesto_end', 'string'],
|
|
['manifesto_paragraphs', 'json', { interface: 'list', note: 'Array of paragraph strings' }],
|
|
// pricing categories (4 cards on home)
|
|
['pricing_categories', 'json', { interface: 'list', note: 'Array of {num, name, slug, startsAt, formules}' }],
|
|
// contact
|
|
['contact_title', 'string'],
|
|
['contact_body', 'text'],
|
|
['contact_addr', 'string'],
|
|
];
|
|
|
|
for (const [name, type, meta = {}, schema = {}] of settingsFields) {
|
|
await ensureField('site_settings', name, type, meta, schema);
|
|
}
|
|
|
|
await ensurePublicRead('site_settings');
|
|
|
|
await setSingleton('site_settings', {
|
|
studio_name: 'Mostuki Photo',
|
|
city: 'CANNES',
|
|
region: 'PACA',
|
|
country: 'FR',
|
|
coords: '43.55N 7.02E',
|
|
email: 'corentin.jog@gmail.com',
|
|
est_year: 2018,
|
|
current_year: 2026,
|
|
hero_tag: '[01] STUDIO.',
|
|
hero_display_lines: ['STUDIO PHOTO.', 'CANNES / PACA.'],
|
|
hero_italic_line: 'documentaire.',
|
|
hero_lede: 'MARIAGE / PORTRAIT / REPORTAGE / EVENEMENTIEL. UNE METHODE EDITORIALE ET DOCUMENTAIRE QUI VAUT POUR TOUS LES TERRAINS.',
|
|
hero_stat: 'ON OBSERVE · ON ENREGISTRE · ON DERANGE PEU',
|
|
hero_year_prefix: '20',
|
|
hero_year_suffix: '26',
|
|
manifesto_tag: '[02] MANIFESTE.',
|
|
manifesto_italic: 'moins de poses,',
|
|
manifesto_end: 'PLUS DE REGARDS.',
|
|
manifesto_paragraphs: [
|
|
"STUDIO PHOTO BASE A CANNES, COUVERTURE PACA ET PARTOUT EN FRANCE. APPROCHE DOCUMENTAIRE : ON OBSERVE, ON ENREGISTRE, ON DERANGE PEU.",
|
|
"QUATRE TERRAINS, UNE METHODE : MARIAGE, PORTRAIT / LIFESTYLE, REPORTAGE / EDITORIAL, EVENEMENTIEL. LE STYLE NE CHANGE PAS AVEC LE SUJET.",
|
|
],
|
|
pricing_categories: [
|
|
{ num: '01', name: 'MARIAGE', slug: 'mariage', startsAt: '1 200€', formules: 'DEMI-JOURNEE / JOURNEE / SUR-MESURE' },
|
|
{ num: '02', name: 'PORTRAIT / LIFESTYLE', slug: 'portrait', startsAt: '400€', formules: 'SEANCE 1H / ETENDU / SUR-MESURE' },
|
|
{ num: '03', name: 'REPORTAGE / EDITORIAL', slug: 'reportage', startsAt: '500€', formules: 'DEMI-JOURNEE / JOURNEE / PROJET' },
|
|
{ num: '04', name: 'EVENEMENTIEL', slug: 'evenementiel', startsAt: '700€', formules: '2H / DEMI-JOURNEE / JOURNEE' },
|
|
],
|
|
contact_title: 'ON SE PARLE ?',
|
|
contact_body: "DECRIS-MOI TON PROJET EN QUELQUES LIGNES : TYPE DE SHOOT, DATE APPROXIMATIVE, LIEU, BUDGET INDICATIF. REPONSE SOUS 48H.",
|
|
contact_addr: 'STUDIO MOSTUKI · CANNES · FR',
|
|
});
|
|
|
|
// ============ series (collection) ============
|
|
console.log('\n=== series (collection) ===');
|
|
await ensureCollection('series', {
|
|
icon: 'collections',
|
|
note: 'Series du portfolio. Une entree par shoot.',
|
|
sort_field: 'sort',
|
|
}, {});
|
|
|
|
const seriesFields = [
|
|
['status', 'string', { interface: 'select-dropdown', options: { choices: [{ text: 'Publie', value: 'published' }, { text: 'Brouillon', value: 'draft' }, { text: 'Archive', value: 'archived' }] } }, { default_value: 'published' }],
|
|
['sort', 'integer', { interface: 'input', hidden: true }],
|
|
['index', 'integer', { width: 'half' }],
|
|
['category', 'string', { width: 'half', interface: 'select-dropdown', options: { choices: [
|
|
{ text: 'Mariage', value: 'mariage' },
|
|
{ text: 'Portrait / Lifestyle', value: 'portrait-lifestyle' },
|
|
{ text: 'Reportage / Editorial', value: 'reportage-editorial' },
|
|
{ text: 'Evenementiel', value: 'evenementiel' },
|
|
] } }],
|
|
['title', 'string'],
|
|
['lieu', 'string', { width: 'half' }],
|
|
['date_shoot', 'string', { width: 'half', note: 'Format libre ex: "Mai 2026" ou "05/2026"' }],
|
|
['cover_alt', 'string'],
|
|
['excerpt', 'text', { interface: 'input-multiline' }],
|
|
['cover', 'uuid', { width: 'half', interface: 'file-image', special: ['file'] }],
|
|
// mariage-specific (optional)
|
|
['couple', 'string', { width: 'half', note: 'Mariage uniquement' }],
|
|
['invites', 'integer', { width: 'half', note: 'Mariage uniquement' }],
|
|
['formule', 'string', { width: 'half', interface: 'select-dropdown', options: { choices: [
|
|
{ text: 'Demi-journee', value: 'demi-journee' },
|
|
{ text: 'Journee complete', value: 'journee-complete' },
|
|
{ text: 'Sur-mesure', value: 'sur-mesure' },
|
|
] }, note: 'Mariage uniquement' }],
|
|
// generic (optional)
|
|
['client', 'string', { width: 'half', note: 'Reportage / Evenementiel' }],
|
|
['duree', 'string', { width: 'half', note: 'Ex: "2h", "Journee"' }],
|
|
['published', 'boolean', { width: 'half', interface: 'boolean' }, { default_value: true }],
|
|
];
|
|
|
|
for (const [name, type, meta = {}, schema = {}] of seriesFields) {
|
|
await ensureField('series', name, type, meta, schema);
|
|
}
|
|
|
|
await ensureRelation('series', 'cover', 'directus_files');
|
|
await ensureM2MFiles('series', 'gallery');
|
|
|
|
await ensurePublicRead('series');
|
|
await ensurePublicRead('directus_files');
|
|
|
|
console.log('\n=== Done ===');
|
|
console.log(`\n→ Open ${URL_BASE} and login with ${EMAIL}`);
|
|
console.log(`→ site_settings has been pre-filled with defaults`);
|
|
console.log(`→ series collection is empty — add entries via the UI`);
|
|
})().catch((e) => {
|
|
console.error('\nFAILED:', e.message);
|
|
process.exit(1);
|
|
});
|