diff --git a/public/css/style.css b/public/css/style.css
index 408ae61..fa03533 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -479,6 +479,25 @@ h3 { font-size: 1rem; margin: 0 0 0.5rem; }
.tutorial-label a { color: var(--accent); text-decoration: none; }
+.tutorial-text-wrap { display: flex; flex-direction: column; gap: 0.25rem; }
+
+.tutorial-text {
+ margin: 0;
+ max-height: 4.5em;
+ overflow: hidden;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: ui-monospace, Consolas, monospace;
+ font-size: 0.8rem;
+ background: var(--surface-alt, rgba(127, 127, 127, 0.08));
+ border-radius: 6px;
+ padding: 0.4rem 0.5rem;
+}
+
+.tutorial-text.expanded { max-height: none; }
+
+.tutorial-text-toggle { align-self: flex-start; font-size: 0.75rem; padding: 0.15rem 0.5rem; }
+
.song-body {
flex: 1 1 auto;
min-width: 0;
@@ -1198,6 +1217,7 @@ button.calendar-filters-close { display: none; }
.profile-header { flex-direction: column; text-align: center; }
+ button.calendar-filters-toggle,
button.calendar-filters-close { display: inline-flex; }
.calendar-header-actions { width: 100%; }
diff --git a/public/js/repertoire.js b/public/js/repertoire.js
index 413cee4..3ab3472 100644
--- a/public/js/repertoire.js
+++ b/public/js/repertoire.js
@@ -79,7 +79,8 @@ function songCardTemplate(song) {
-
+
+
@@ -107,7 +108,31 @@ function editSongCardTemplate(song) {
`;
}
+function tutorialTextBlockTemplate(content) {
+ return `
+
+
${escapeHtml(content)}
+
+
+ `;
+}
+
function tutorialCardTemplate(t) {
+ const textBlock = t.content ? tutorialTextBlockTemplate(t.content) : '';
+
+ if (!t.url) {
+ return `
+
+
+ ${textBlock}
+
+
+ `;
+ }
+
const thumb = youtubeThumbnailUrl(t.url);
if (thumb) {
return `
@@ -120,6 +145,7 @@ function tutorialCardTemplate(t) {
${escapeHtml(t.instrument_name)}
${escapeHtml(t.label || 'Tuto')}
+ ${textBlock}
`;
@@ -131,6 +157,7 @@ function tutorialCardTemplate(t) {
${escapeHtml(t.instrument_name)}
+ ${textBlock}
`;
@@ -195,6 +222,13 @@ async function loadTutorials(songId) {
container.querySelectorAll('.remove-tutorial').forEach((btn) => {
btn.addEventListener('click', () => onRemoveTutorial(parseInt(btn.dataset.songId, 10), parseInt(btn.dataset.id, 10)));
});
+ container.querySelectorAll('.tutorial-text-toggle').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const text = btn.previousElementSibling;
+ const isExpanded = text.classList.toggle('expanded');
+ btn.textContent = isExpanded ? 'Voir moins' : 'Voir plus';
+ });
+ });
} catch (err) {
showError(err.message);
}
@@ -206,9 +240,14 @@ async function onAddTutorial(e) {
const songId = parseInt(form.dataset.songId, 10);
const instrumentId = parseInt(form.instrumentId.value, 10);
const url = form.url.value.trim();
+ const content = form.content.value.trim();
const label = form.label.value.trim();
+ if (!url && !content) {
+ showError('Renseignez un lien ou un texte pour le tuto.');
+ return;
+ }
try {
- await api.post(`/api/songs/${songId}/tutorials`, { instrumentId, url, label });
+ await api.post(`/api/songs/${songId}/tutorials`, { instrumentId, url, content, label });
form.reset();
const song = songs.find((s) => s.id === songId);
if (song) song.tutorial_count += 1;
diff --git a/src/db/migrations/017_song_tutorial_content.sql b/src/db/migrations/017_song_tutorial_content.sql
new file mode 100644
index 0000000..1ab0ed4
--- /dev/null
+++ b/src/db/migrations/017_song_tutorial_content.sql
@@ -0,0 +1,3 @@
+ALTER TABLE song_tutorials
+ ALTER COLUMN url DROP NOT NULL,
+ ADD COLUMN content TEXT;
diff --git a/src/repositories/songsRepo.js b/src/repositories/songsRepo.js
index 99a8cbd..0978b13 100644
--- a/src/repositories/songsRepo.js
+++ b/src/repositories/songsRepo.js
@@ -48,7 +48,7 @@ async function remove(id) {
async function findTutorials(songId) {
const { rows } = await pool.query(
`SELECT st.id, st.song_id, st.instrument_id, i.name AS instrument_name,
- st.url, st.label, st.added_by, st.created_at
+ st.url, st.content, st.label, st.added_by, st.created_at
FROM song_tutorials st
JOIN instruments i ON i.id = st.instrument_id
WHERE st.song_id = $1
@@ -58,12 +58,12 @@ async function findTutorials(songId) {
return rows;
}
-async function addTutorial(songId, { instrumentId, url, label, addedBy }) {
+async function addTutorial(songId, { instrumentId, url, content, label, addedBy }) {
const { rows } = await pool.query(
- `INSERT INTO song_tutorials (song_id, instrument_id, url, label, added_by)
- VALUES ($1, $2, $3, $4, $5)
- RETURNING id, song_id, instrument_id, url, label, added_by, created_at`,
- [songId, instrumentId, url, label || null, addedBy]
+ `INSERT INTO song_tutorials (song_id, instrument_id, url, content, label, added_by)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING id, song_id, instrument_id, url, content, label, added_by, created_at`,
+ [songId, instrumentId, url || null, content || null, label || null, addedBy]
);
return rows[0];
}
diff --git a/src/routes/songs.js b/src/routes/songs.js
index 3746b81..2d7834a 100644
--- a/src/routes/songs.js
+++ b/src/routes/songs.js
@@ -91,15 +91,21 @@ router.delete(
router.post(
'/:id/tutorials',
asyncHandler(async (req, res) => {
- const { instrumentId, url, label } = req.body || {};
- if (!instrumentId || !url || !url.trim()) {
- return res.status(400).json({ error: 'instrument_and_url_required' });
+ const { instrumentId, url, content, label } = req.body || {};
+ const trimmedUrl = url && url.trim() ? url.trim() : null;
+ const trimmedContent = content && content.trim() ? content.trim() : null;
+ if (!instrumentId) {
+ return res.status(400).json({ error: 'instrument_required' });
+ }
+ if (!trimmedUrl && !trimmedContent) {
+ return res.status(400).json({ error: 'url_or_content_required' });
}
const song = await songsRepo.findById(req.params.id);
if (!song) return res.status(404).json({ error: 'not_found' });
const tutorial = await songsRepo.addTutorial(req.params.id, {
instrumentId,
- url: url.trim(),
+ url: trimmedUrl,
+ content: trimmedContent,
label,
addedBy: req.user.id,
});