feat: convert all slide panels to tabbed modals with shared TabbedModal component
All checks were successful
Deploy / deploy (push) Successful in 11s
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:
231
server/server.js
231
server/server.js
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user