feat: convert all slide panels to tabbed modals with shared TabbedModal component
All checks were successful
Deploy / deploy (push) Successful in 11s

Extract reusable TabbedModal component (portal, backdrop, tab bar with icons/badges/underline, scrollable body, footer) and convert all 9 detail panels from SlidePanel+CollapsibleSection to tabbed modal layout:
- PostDetailPanel (5 tabs), TaskDetailPanel (3), ProjectEditPanel (2)
- TrackDetailPanel (2), CampaignDetailPanel (3), TeamMemberPanel (3)
- TeamPanel (2), IssueDetailPanel (4), ArtefactDetailPanel (4)
Also adds post versioning system (server routes + frontend).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-09 17:12:32 +03:00
parent 539c204bde
commit 44e706f777
14 changed files with 2839 additions and 1921 deletions

View File

@@ -154,6 +154,8 @@ const FK_COLUMNS = {
Comments: ['user_id'],
BudgetEntries: ['campaign_id', 'project_id'],
Artefacts: ['project_id', 'campaign_id'],
PostVersions: ['post_id', 'created_by_user_id'],
PostVersionTexts: ['version_id'],
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
Users: ['role_id'],
};
@@ -367,6 +369,19 @@ const REQUIRED_TABLES = {
{ title: 'size', uidt: 'Number' },
{ title: 'drive_url', uidt: 'SingleLineText' },
],
PostVersions: [
{ title: 'post_id', uidt: 'Number' },
{ title: 'version_number', uidt: 'Number' },
{ title: 'created_by_user_id', uidt: 'Number' },
{ title: 'created_at', uidt: 'DateTime' },
{ title: 'notes', uidt: 'LongText' },
],
PostVersionTexts: [
{ title: 'version_id', uidt: 'Number' },
{ title: 'language_code', uidt: 'SingleLineText' },
{ title: 'language_label', uidt: 'SingleLineText' },
{ title: 'content', uidt: 'LongText' },
],
Issues: [
{ title: 'title', uidt: 'SingleLineText' },
{ title: 'description', uidt: 'LongText' },
@@ -471,7 +486,10 @@ const TEXT_COLUMNS = {
{ name: 'approved_by_name', uidt: 'SingleLineText' },
{ name: 'approved_at', uidt: 'SingleLineText' },
{ name: 'feedback', uidt: 'LongText' },
{ name: 'current_version', uidt: 'Number' },
{ name: 'review_version', uidt: 'Number' },
],
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
};
async function ensureTextColumns() {
@@ -1508,6 +1526,10 @@ app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts'
updateData.approved_at = null;
updateData.feedback = null;
}
// Track which version is under review
if (existing.current_version) {
updateData.review_version = existing.current_version;
}
await nocodb.update('Posts', req.params.id, updateData);
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
@@ -1640,6 +1662,215 @@ app.post('/api/public/review-post/:token/reject', async (req, res) => {
}
});
// ─── POST VERSIONS ──────────────────────────────────────────────
// List all versions for a post
app.get('/api/posts/:id/versions', requireAuth, async (req, res) => {
try {
const versions = await nocodb.list('PostVersions', {
where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
sort: 'version_number',
limit: QUERY_LIMITS.large,
});
const enriched = [];
for (const v of versions) {
const creatorName = await getRecordName('Users', v.created_by_user_id);
enriched.push({ ...v, creator_name: creatorName });
}
res.json(enriched);
} catch (err) {
console.error('List post versions error:', err);
res.status(500).json({ error: 'Failed to load versions' });
}
});
// Create new version
app.post('/api/posts/:id/versions', requireAuth, async (req, res) => {
const { notes, copy_from_previous } = req.body;
try {
const post = await nocodb.get('Posts', req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only create versions for your own posts' });
}
const versions = await nocodb.list('PostVersions', {
where: `(post_id,eq,${sanitizeWhereValue(req.params.id)})`,
sort: '-version_number',
limit: 1,
});
const newVersionNumber = versions.length > 0 ? versions[0].version_number + 1 : 1;
const created = await nocodb.create('PostVersions', {
post_id: Number(req.params.id),
version_number: newVersionNumber,
created_by_user_id: req.session.userId,
created_at: new Date().toISOString(),
notes: notes || `Version ${newVersionNumber}`,
});
await nocodb.update('Posts', req.params.id, { current_version: newVersionNumber });
// Copy texts from previous version if requested
if (copy_from_previous && versions.length > 0) {
const prevVersionId = versions[0].Id;
const prevTexts = await nocodb.list('PostVersionTexts', {
where: `(version_id,eq,${prevVersionId})`,
limit: QUERY_LIMITS.large,
});
for (const text of prevTexts) {
await nocodb.create('PostVersionTexts', {
version_id: created.Id,
language_code: text.language_code,
language_label: text.language_label,
content: text.content,
});
}
}
const version = await nocodb.get('PostVersions', created.Id);
const creatorName = await getRecordName('Users', version.created_by_user_id);
res.status(201).json({ ...version, creator_name: creatorName });
} catch (err) {
console.error('Create post version error:', err);
res.status(500).json({ error: 'Failed to create version' });
}
});
// Get specific version with texts and attachments
app.get('/api/posts/:id/versions/:versionId', requireAuth, async (req, res) => {
try {
const version = await nocodb.get('PostVersions', req.params.versionId);
if (!version) return res.status(404).json({ error: 'Version not found' });
if (version.post_id !== Number(req.params.id)) {
return res.status(400).json({ error: 'Version does not belong to this post' });
}
const [texts, attachments] = await Promise.all([
nocodb.list('PostVersionTexts', {
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
limit: QUERY_LIMITS.large,
}),
nocodb.list('PostAttachments', {
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})`,
limit: QUERY_LIMITS.large,
}),
]);
const creatorName = await getRecordName('Users', version.created_by_user_id);
res.json({
...version,
creator_name: creatorName,
texts,
attachments: attachments.map(a => ({
...a,
url: a.url || `/api/uploads/${a.filename}`,
})),
});
} catch (err) {
console.error('Get post version error:', err);
res.status(500).json({ error: 'Failed to load version' });
}
});
// Add/update language text for a version
app.post('/api/posts/:id/versions/:versionId/texts', requireAuth, async (req, res) => {
const { language_code, language_label, content } = req.body;
if (!language_code || !language_label || !content) {
return res.status(400).json({ error: 'language_code, language_label, and content are required' });
}
try {
const post = await nocodb.get('Posts', req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only manage texts for your own posts' });
}
const existing = await nocodb.list('PostVersionTexts', {
where: `(version_id,eq,${sanitizeWhereValue(req.params.versionId)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
limit: 1,
});
let text;
if (existing.length > 0) {
await nocodb.update('PostVersionTexts', existing[0].Id, { language_label, content });
text = await nocodb.get('PostVersionTexts', existing[0].Id);
} else {
const created = await nocodb.create('PostVersionTexts', {
version_id: Number(req.params.versionId),
language_code,
language_label,
content,
});
text = await nocodb.get('PostVersionTexts', created.Id);
}
res.json(text);
} catch (err) {
console.error('Add/update post text error:', err);
res.status(500).json({ error: 'Failed to add/update text' });
}
});
// Delete language text
app.delete('/api/post-version-texts/:id', requireAuth, async (req, res) => {
try {
const text = await nocodb.get('PostVersionTexts', req.params.id);
if (!text) return res.status(404).json({ error: 'Text not found' });
const version = await nocodb.get('PostVersions', text.version_id);
const post = await nocodb.get('Posts', version.post_id);
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
return res.status(403).json({ error: 'You can only manage texts for your own posts' });
}
await nocodb.delete('PostVersionTexts', req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Delete post text error:', err);
res.status(500).json({ error: 'Failed to delete text' });
}
});
// Upload attachment to specific version
app.post('/api/posts/:id/versions/:versionId/attachments', requireAuth, dynamicUpload('file'), async (req, res) => {
try {
const post = await nocodb.get('Posts', req.params.id);
if (!post) return res.status(404).json({ error: 'Post not found' });
if (req.session.userRole === 'contributor' && post.created_by_user_id !== req.session.userId && post.assigned_to_id !== req.session.userId) {
if (req.file) fs.unlinkSync(path.join(uploadsDir, req.file.filename));
return res.status(403).json({ error: 'You can only manage attachments on your own posts' });
}
if (!req.file) {
return res.status(400).json({ error: 'File upload is required' });
}
const url = `/api/uploads/${req.file.filename}`;
const created = await nocodb.create('PostAttachments', {
filename: req.file.filename,
original_name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
url,
post_id: Number(req.params.id),
version_id: Number(req.params.versionId),
});
const attachment = await nocodb.get('PostAttachments', created.Id);
res.status(201).json(attachment);
} catch (err) {
console.error('Upload post version attachment error:', err);
res.status(500).json({ error: 'Failed to upload attachment' });
}
});
// ─── ASSETS ─────────────────────────────────────────────────────
app.get('/api/assets', requireAuth, async (req, res) => {