From f9959f82652fbde25003f4cefc1713cd0d377c46 Mon Sep 17 00:00:00 2001 From: Olivier Baumann Date: Wed, 7 Jan 2026 11:17:33 +0100 Subject: [PATCH 1/7] private file check --- .../components/Publication/useValidation.tsx | 58 +++++++++++++++++++ apps/publikator/lib/translations.json | 12 ++++ 2 files changed, 70 insertions(+) diff --git a/apps/publikator/components/Publication/useValidation.tsx b/apps/publikator/components/Publication/useValidation.tsx index 50f5087c92..6b794ddc19 100644 --- a/apps/publikator/components/Publication/useValidation.tsx +++ b/apps/publikator/components/Publication/useValidation.tsx @@ -9,6 +9,24 @@ import { Editorial, mdastToString } from '@project-r/styleguide' // Used to check for relative urls const FAKE_BASE_URL = `http://${uuid()}.local` +/** + * Check if a URL is a private S3 signed URL + * Private URLs contain AWS signature parameters like X-Amz-Signature + */ +const isPrivateAssetUrl = (url: string): boolean => { + try { + const urlObject = new URL(url) + // AWS signed URLs contain these parameters + return ( + urlObject.searchParams.has('X-Amz-Signature') || + urlObject.searchParams.has('X-Amz-Algorithm') || + urlObject.searchParams.has('X-Amz-Credential') + ) + } catch { + return false + } +} + const useValidation = ({ meta, content, t, updateMailchimp }) => { const links = useMemo(() => { const toText = mdastToString @@ -47,6 +65,10 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { ) { warnings.push('wwwws') } + // Check for private S3 signed URLs + if (isPrivateAssetUrl(node[urlKey])) { + warnings.push('privateAsset') + } } catch (e) { console.log('Error validating URL', e) } @@ -62,6 +84,22 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { return all }, [content]) + // Check for private asset URLs in audio and other file fields + const privateAssets = useMemo(() => { + const assets: Array<{ url: string; location: string }> = [] + + // Check audio source in metadata + const audioSourceMp3 = meta?.audioSourceMp3 || content?.meta?.audioSourceMp3 + if (audioSourceMp3 && isPrivateAssetUrl(audioSourceMp3)) { + assets.push({ + url: audioSourceMp3, + location: t('publish/validation/privateAsset/audio'), + }) + } + + return assets + }, [content, meta, t]) + const errors = [ meta.template !== 'front' && !meta.slug && @@ -145,6 +183,26 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { [], ), ) + // Add warnings for private asset URLs (files not yet made public) + .concat( + privateAssets.map((asset) => + t.elements('publish/validation/privateAsset/warning', { + location: asset.location, + link: ( + + {asset.url.length > 80 + ? asset.url.substring(0, 80) + '...' + : asset.url} + + ), + }), + ), + ) return { errors, warnings, links } } diff --git a/apps/publikator/lib/translations.json b/apps/publikator/lib/translations.json index 483c29e9c4..7ceace9517 100644 --- a/apps/publikator/lib/translations.json +++ b/apps/publikator/lib/translations.json @@ -1148,6 +1148,18 @@ "key": "publish/validation/link/issue/profiles", "value": "Profile sollten immer via ID verlinkt werden." }, + { + "key": "publish/validation/link/issue/privateAsset", + "value": "Diese Datei ist nicht öffentlich zugänglich. Bitte unter «Dateien» veröffentlichen." + }, + { + "key": "publish/validation/privateAsset/warning", + "value": "Nicht öffentliche Datei ({location}): {link} – Bitte unter «Dateien» veröffentlichen." + }, + { + "key": "publish/validation/privateAsset/audio", + "value": "Audiodatei" + }, { "key": "publish/validation/hasErrors", "value": "Diese Version kann nicht veröffentlicht werden:" From 14e373f0faab17a312842cf377ce2ac3f7100a43 Mon Sep 17 00:00:00 2001 From: Olivier Baumann Date: Wed, 7 Jan 2026 11:26:47 +0100 Subject: [PATCH 2/7] audio ms fix and validation --- .../components/Publication/useValidation.tsx | 8 ++++++++ apps/publikator/lib/translations.json | 4 ++++ .../backend-modules/publikator/lib/audioSource.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/apps/publikator/components/Publication/useValidation.tsx b/apps/publikator/components/Publication/useValidation.tsx index 6b794ddc19..c7f7d7bf0a 100644 --- a/apps/publikator/components/Publication/useValidation.tsx +++ b/apps/publikator/components/Publication/useValidation.tsx @@ -100,6 +100,12 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { return assets }, [content, meta, t]) + // Check if audio source exists but duration is missing + const audioSourceMp3 = meta?.audioSourceMp3 || content?.meta?.audioSourceMp3 + const audioSourceDurationMs = + meta?.audioSourceDurationMs || content?.meta?.audioSourceDurationMs + const hasAudioWithoutDuration = !!audioSourceMp3 && !audioSourceDurationMs + const errors = [ meta.template !== 'front' && !meta.slug && @@ -111,6 +117,8 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { !content.meta.suppressSyntheticReadAloud && !content.meta.syntheticVoice && t('publish/validation/syntheticVoice/empty'), + hasAudioWithoutDuration && + t('publish/validation/audioSourceDurationMs/missing'), ].filter(Boolean) const socialWarnings = SOCIAL_MEDIA.map( diff --git a/apps/publikator/lib/translations.json b/apps/publikator/lib/translations.json index 7ceace9517..14ce06b28b 100644 --- a/apps/publikator/lib/translations.json +++ b/apps/publikator/lib/translations.json @@ -1192,6 +1192,10 @@ "key": "publish/validation/syntheticVoice/empty", "value": "Synthetische Stimme fehlt" }, + { + "key": "publish/validation/audioSourceDurationMs/missing", + "value": "Audio-Datei vorhanden, aber Dauer fehlt. Audio-Datei neu hinzufügen und committen um Fehler zu beheben." + }, { "key": "publish/validation/facebookImage/empty", "value": "Bild für Facebook fehlt" diff --git a/packages/backend-modules/publikator/lib/audioSource.ts b/packages/backend-modules/publikator/lib/audioSource.ts index d794b3f20d..7b76ee4fc2 100644 --- a/packages/backend-modules/publikator/lib/audioSource.ts +++ b/packages/backend-modules/publikator/lib/audioSource.ts @@ -37,6 +37,7 @@ export const maybeApplyAudioSourceDuration = async ( (!previousMeta || previousMeta.audioSourceMp3 !== currentMeta.audioSourceMp3) ) { + // Audio URL is new or changed - fetch and calculate duration debug( 'fetching audio source and measure duration: %s', currentMeta.audioSourceMp3, @@ -55,6 +56,19 @@ export const maybeApplyAudioSourceDuration = async ( console.error(e, currentMeta.audioSourceMp3) throw e }) + } else if ( + !!currentMeta.audioSourceMp3 && + previousMeta?.audioSourceMp3 === currentMeta.audioSourceMp3 && + previousMeta?.audioSourceDurationMs && + !currentMeta.audioSourceDurationMs + ) { + // Audio URL unchanged but duration missing from current commit (race condition fix) + // Preserve duration from previous commit + debug( + 'preserving audioSourceDurationMs from previous commit: %s', + previousMeta.audioSourceDurationMs, + ) + currentMeta.audioSourceDurationMs = previousMeta.audioSourceDurationMs } if (!currentMeta.audioSourceMp3 && 'audioSourceDurationMs' in currentMeta) { From a39e0faf063d1f60bfcdd6245793b13b3e19acf0 Mon Sep 17 00:00:00 2001 From: Olivier Baumann Date: Wed, 7 Jan 2026 11:49:00 +0100 Subject: [PATCH 3/7] fix translation to be more accurate --- apps/publikator/lib/translations.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/publikator/lib/translations.json b/apps/publikator/lib/translations.json index 14ce06b28b..7b988c3560 100644 --- a/apps/publikator/lib/translations.json +++ b/apps/publikator/lib/translations.json @@ -1194,7 +1194,7 @@ }, { "key": "publish/validation/audioSourceDurationMs/missing", - "value": "Audio-Datei vorhanden, aber Dauer fehlt. Audio-Datei neu hinzufügen und committen um Fehler zu beheben." + "value": "Audio-Datei vorhanden, aber Dauer fehlt. Vorherige Dokumentversion verwenden oder aus vorherigem Quellcode kopieren." }, { "key": "publish/validation/facebookImage/empty", From 0e8f2fde0d7dfcb4673a7a7cee8024672714f1a0 Mon Sep 17 00:00:00 2001 From: Olivier Baumann Date: Wed, 7 Jan 2026 12:46:53 +0100 Subject: [PATCH 4/7] error instead of warning for privateAssets --- .../components/Publication/useValidation.tsx | 89 +++++++++---------- apps/publikator/lib/translations.json | 4 +- 2 files changed, 45 insertions(+), 48 deletions(-) diff --git a/apps/publikator/components/Publication/useValidation.tsx b/apps/publikator/components/Publication/useValidation.tsx index c7f7d7bf0a..b01e38779f 100644 --- a/apps/publikator/components/Publication/useValidation.tsx +++ b/apps/publikator/components/Publication/useValidation.tsx @@ -65,9 +65,9 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { ) { warnings.push('wwwws') } - // Check for private S3 signed URLs + // Check for private S3 signed URLs - block publication if (isPrivateAssetUrl(node[urlKey])) { - warnings.push('privateAsset') + errors.push('privateAsset') } } catch (e) { console.log('Error validating URL', e) @@ -119,6 +119,24 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { t('publish/validation/syntheticVoice/empty'), hasAudioWithoutDuration && t('publish/validation/audioSourceDurationMs/missing'), + // Add errors for private asset URLs in audio files + ...privateAssets.map((asset) => + t.elements('publish/validation/privateAsset/error', { + location: asset.location, + link: ( + + {asset.url.length > 80 + ? asset.url.substring(0, 80) + '...' + : asset.url} + + ), + }), + ), ].filter(Boolean) const socialWarnings = SOCIAL_MEDIA.map( @@ -165,54 +183,33 @@ const useValidation = ({ meta, content, t, updateMailchimp }) => { [], ), ) - // to start we do not block any publication - .concat( - links - .filter(({ errors }) => errors.length) - .reduce( - (all, link) => - all.concat( - link.errors.map((error) => - t.elements('publish/validation/link/error', { - text: link.text, - link: ( - - {link.url} - - ), - reason: t(`publish/validation/link/issue/${error}`), - }), + + // Add link errors to the errors array to block publication + const linkErrors = links + .filter(({ errors }) => errors.length) + .reduce( + (all, link) => + all.concat( + link.errors.map((error) => + t.elements('publish/validation/link/error', { + text: link.text, + link: ( + + {link.url} + ), - ), - [], - ), - ) - // Add warnings for private asset URLs (files not yet made public) - .concat( - privateAssets.map((asset) => - t.elements('publish/validation/privateAsset/warning', { - location: asset.location, - link: ( - - {asset.url.length > 80 - ? asset.url.substring(0, 80) + '...' - : asset.url} - + reason: t(`publish/validation/link/issue/${error}`), + }), ), - }), - ), + ), + [], ) - return { errors, warnings, links } + return { errors: [...errors, ...linkErrors], warnings, links } } export default useValidation diff --git a/apps/publikator/lib/translations.json b/apps/publikator/lib/translations.json index 7b988c3560..5cde5dbfc6 100644 --- a/apps/publikator/lib/translations.json +++ b/apps/publikator/lib/translations.json @@ -1153,7 +1153,7 @@ "value": "Diese Datei ist nicht öffentlich zugänglich. Bitte unter «Dateien» veröffentlichen." }, { - "key": "publish/validation/privateAsset/warning", + "key": "publish/validation/privateAsset/error", "value": "Nicht öffentliche Datei ({location}): {link} – Bitte unter «Dateien» veröffentlichen." }, { @@ -1194,7 +1194,7 @@ }, { "key": "publish/validation/audioSourceDurationMs/missing", - "value": "Audio-Datei vorhanden, aber Dauer fehlt. Vorherige Dokumentversion verwenden oder aus vorherigem Quellcode kopieren." + "value": "Audio-Datei vorhanden, aber Dauer fehlt. Vorherige Dokumentversion verwenden oder Audio-File neu hochladen." }, { "key": "publish/validation/facebookImage/empty", From b9311afd9bf1521722f9c2a91bb3f72bb1dd126a Mon Sep 17 00:00:00 2001 From: Olivier Baumann Date: Wed, 7 Jan 2026 12:51:32 +0100 Subject: [PATCH 5/7] remove audioSource.ts changes --- .../backend-modules/publikator/lib/audioSource.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/backend-modules/publikator/lib/audioSource.ts b/packages/backend-modules/publikator/lib/audioSource.ts index 7b76ee4fc2..d794b3f20d 100644 --- a/packages/backend-modules/publikator/lib/audioSource.ts +++ b/packages/backend-modules/publikator/lib/audioSource.ts @@ -37,7 +37,6 @@ export const maybeApplyAudioSourceDuration = async ( (!previousMeta || previousMeta.audioSourceMp3 !== currentMeta.audioSourceMp3) ) { - // Audio URL is new or changed - fetch and calculate duration debug( 'fetching audio source and measure duration: %s', currentMeta.audioSourceMp3, @@ -56,19 +55,6 @@ export const maybeApplyAudioSourceDuration = async ( console.error(e, currentMeta.audioSourceMp3) throw e }) - } else if ( - !!currentMeta.audioSourceMp3 && - previousMeta?.audioSourceMp3 === currentMeta.audioSourceMp3 && - previousMeta?.audioSourceDurationMs && - !currentMeta.audioSourceDurationMs - ) { - // Audio URL unchanged but duration missing from current commit (race condition fix) - // Preserve duration from previous commit - debug( - 'preserving audioSourceDurationMs from previous commit: %s', - previousMeta.audioSourceDurationMs, - ) - currentMeta.audioSourceDurationMs = previousMeta.audioSourceDurationMs } if (!currentMeta.audioSourceMp3 && 'audioSourceDurationMs' in currentMeta) { From 5955cddfe07dd1695e024ad47e446bdd7df3d8da Mon Sep 17 00:00:00 2001 From: Olivier Baumann Date: Wed, 7 Jan 2026 14:38:36 +0100 Subject: [PATCH 6/7] prevent assets from being unpublished if they are in the doc --- apps/publikator/components/Files/Row.js | 126 +++++++++++++-- .../components/Files/actions/Destroy.js | 2 +- .../components/Files/actions/Publish.js | 2 +- .../components/Files/actions/Unpublish.js | 19 ++- apps/publikator/components/Files/index.js | 23 ++- .../Files/utils/extractUrlsFromContent.js | 143 ++++++++++++++++++ 6 files changed, 295 insertions(+), 20 deletions(-) create mode 100644 apps/publikator/components/Files/utils/extractUrlsFromContent.js diff --git a/apps/publikator/components/Files/Row.js b/apps/publikator/components/Files/Row.js index e36fdde319..06d921d08f 100644 --- a/apps/publikator/components/Files/Row.js +++ b/apps/publikator/components/Files/Row.js @@ -1,8 +1,15 @@ +import { useState } from 'react' import { css } from 'glamor' -import { IconLock, IconPublic, IconError, IconReadTime } from '@republik/icons' +import { + IconLock, + IconPublic, + IconError, + IconReadTime, + IconLink, +} from '@republik/icons' -import { IconButton, Label } from '@project-r/styleguide' +import { A, Button, Label, useColorContext } from '@project-r/styleguide' import { swissTime } from '../../lib/utils/format' @@ -15,8 +22,31 @@ import Unpublish from './actions/Unpublish' const timeFormat = swissTime.format('%d. %B %Y, %H:%M Uhr') const styles = { + fileRow: css({ + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + }), + fileName: css({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '400px', + }), label: css({ - marginLeft: '2rem', + marginLeft: '1.5rem', + }), + usageInfo: css({ + marginLeft: '1.5rem', + marginTop: '0.25rem', + display: 'flex', + alignItems: 'center', + gap: '0.25rem', + }), + actions: css({ + display: 'flex', + gap: '0.5rem', + justifyContent: 'flex-end', }), } @@ -51,22 +81,80 @@ const statusMap = { }, } -const File = ({ file }) => { +const UsageTypeLabels = { + link: 'Link', + image: 'Bild', + embed: 'Embed', + meta: 'Metadaten', +} + +const UsageInfo = ({ usages }) => { + const [colorScheme] = useColorContext() + + if (!usages || usages.length === 0) return null + + // Group usages by type and show summary + const usageText = usages + .map((u) => `${UsageTypeLabels[u.type] || u.type}: "${u.text}"`) + .join(', ') + + return ( +
+ + +
+ ) +} + +const File = ({ file, usages }) => { + const [colorScheme] = useColorContext() const { Icon, disabled, colorName, crumb, Action } = statusMap[file.status] || statusMap.Pending + const isInUse = usages && usages.length > 0 + + const [copied, setCopied] = useState(false) + + const onClick = async () => { + try { + await navigator.clipboard.writeText(file.url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy link:', err) + } + } + return ( - +
+ + {disabled ? ( + + {file.name} + + ) : ( + + {file.name} + + )} +
+ + + +
+ {file.status === 'Public' && ( + + )} + {Action && } +
- {Action && } ) } diff --git a/apps/publikator/components/Files/actions/Destroy.js b/apps/publikator/components/Files/actions/Destroy.js index e46a40f3e7..03bdba613e 100644 --- a/apps/publikator/components/Files/actions/Destroy.js +++ b/apps/publikator/components/Files/actions/Destroy.js @@ -25,7 +25,7 @@ const Destroy = ({ file }) => { } return ( - ) diff --git a/apps/publikator/components/Files/actions/Publish.js b/apps/publikator/components/Files/actions/Publish.js index aa0641b356..5501f4ab19 100644 --- a/apps/publikator/components/Files/actions/Publish.js +++ b/apps/publikator/components/Files/actions/Publish.js @@ -21,7 +21,7 @@ const Publish = ({ file }) => { } return ( - ) diff --git a/apps/publikator/components/Files/actions/Unpublish.js b/apps/publikator/components/Files/actions/Unpublish.js index 66a83075e8..c93745ed13 100644 --- a/apps/publikator/components/Files/actions/Unpublish.js +++ b/apps/publikator/components/Files/actions/Unpublish.js @@ -13,7 +13,7 @@ const MAKE_PRIVATE = gql` ${RepoFile} ` -const Publish = ({ file }) => { +const Unpublish = ({ file, isInUse }) => { const [makePrivate, { loading }] = useMutation(MAKE_PRIVATE) const onClick = () => { @@ -26,11 +26,24 @@ const Publish = ({ file }) => { } } + // Disable the button if the file is being used in the document + const disabled = loading || isInUse + return ( - ) } -export default Publish +export default Unpublish diff --git a/apps/publikator/components/Files/index.js b/apps/publikator/components/Files/index.js index a43d4b0e9b..b095d3c9c2 100644 --- a/apps/publikator/components/Files/index.js +++ b/apps/publikator/components/Files/index.js @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { gql, useQuery } from '@apollo/client' import { Container } from '@project-r/styleguide' import { css } from 'glamor' @@ -13,6 +14,7 @@ import { Table, Th, Tr } from '../Table' import Info from './Info' import Row from './Row' import Upload from './Upload' +import { getFileUsageInContent } from './utils/extractUrlsFromContent' const GET_FILES = gql` query getFiles($id: ID!) { @@ -21,6 +23,13 @@ const GET_FILES = gql` files { ...RepoFile } + latestCommit { + id + document { + id + content + } + } } } @@ -50,6 +59,14 @@ const FilesPage = ({ router, t }) => { }) : queryError + // Compute which files are used in the document content + const fileUsageMap = useMemo(() => { + const content = data?.repo?.latestCommit?.document?.content + const files = data?.repo?.files + if (!content || !files) return new Map() + return getFileUsageInContent(files, content) + }, [data?.repo?.latestCommit?.document?.content, data?.repo?.files]) + return ( @@ -76,7 +93,11 @@ const FilesPage = ({ router, t }) => { {data.repo.files.map((file) => ( - + ))} diff --git a/apps/publikator/components/Files/utils/extractUrlsFromContent.js b/apps/publikator/components/Files/utils/extractUrlsFromContent.js new file mode 100644 index 0000000000..fca1e9aa0f --- /dev/null +++ b/apps/publikator/components/Files/utils/extractUrlsFromContent.js @@ -0,0 +1,143 @@ +import visit from 'unist-util-visit' + +/** + * Metadata fields that may contain unpublished file URLs + */ +const META_URL_FIELDS = [{ key: 'audioSourceMp3', label: 'Audio' }] + +/** + * Extract all URLs from MDAST content that could reference uploaded files. + * This includes links, images, metadata fields (audio, cover image), and other embedded content. + * + * @param {Object} content - The MDAST document content + * @returns {Array<{url: string, type: string, text?: string}>} - Array of found URLs with their context + */ +const extractUrlsFromContent = (content) => { + if (!content) return [] + + const urls = [] + + // Check metadata fields for URLs (audioSourceMp3, image, etc.) + if (content.meta) { + for (const { key, label } of META_URL_FIELDS) { + const url = content.meta[key] + if (url && typeof url === 'string') { + urls.push({ + url, + type: 'meta', + text: label, + }) + } + } + } + + // Visit all link nodes + visit(content, 'link', (node) => { + if (node?.url) { + urls.push({ + url: node.url, + type: 'link', + text: getNodeText(node), + }) + } + }) + + // Visit all image nodes + visit(content, 'image', (node) => { + if (node?.url) { + urls.push({ + url: node.url, + type: 'image', + text: node.alt || node.title || 'Bild', + }) + } + }) + + // Visit zone nodes (embeds, etc.) that may have URLs in their data + visit(content, 'zone', (node) => { + if (node?.data?.url) { + urls.push({ + url: node.data.url, + type: 'embed', + text: node.identifier || 'Embed', + }) + } + }) + + return urls +} + +/** + * Get text content from a node + */ +const getNodeText = (node) => { + if (!node) return '' + if (typeof node.value === 'string') return node.value + if (Array.isArray(node.children)) { + return node.children.map(getNodeText).join('') + } + return '' +} + +/** + * Normalize a URL for comparison by removing query parameters and trailing slashes + * @param {string} url - The URL to normalize + * @returns {string} - The normalized URL + */ +const normalizeUrl = (url) => { + if (!url) return '' + try { + const urlObj = new URL(url) + // Return just the origin + pathname (without query params or hash) + return (urlObj.origin + urlObj.pathname).replace(/\/+$/, '') + } catch { + // If URL parsing fails, just clean up the string + return url.split('?')[0].split('#')[0].replace(/\/+$/, '') + } +} + +/** + * Check if two URLs match (comparing normalized versions) + * @param {string} url1 - First URL + * @param {string} url2 - Second URL + * @returns {boolean} - Whether the URLs match + */ +const urlsMatch = (url1, url2) => { + if (!url1 || !url2) return false + const norm1 = normalizeUrl(url1) + const norm2 = normalizeUrl(url2) + return norm1 === norm2 || norm1.includes(norm2) || norm2.includes(norm1) +} + +/** + * Check which files are being used in the document content. + * Returns a Map of file URLs to their usage contexts. + * + * @param {Array} files - Array of file objects with url property + * @param {Object} content - The MDAST document content + * @returns {Map>} - Map of file URLs to usage contexts + */ +export const getFileUsageInContent = (files, content) => { + const contentUrls = extractUrlsFromContent(content) + const fileUsage = new Map() + + if (!files || !contentUrls.length) { + return fileUsage + } + + for (const file of files) { + if (!file.url) continue + + // Find all places where this file URL is used + const usages = contentUrls.filter((urlInfo) => urlsMatch(file.url, urlInfo.url)) + + if (usages.length > 0) { + fileUsage.set(file.url, usages) + } + } + + return fileUsage +} + +export default extractUrlsFromContent + From b1c04eb933d2117574a7207951e5bad87b7373eb Mon Sep 17 00:00:00 2001 From: Olivier Baumann Date: Mon, 12 Jan 2026 16:24:47 +0100 Subject: [PATCH 7/7] typescript and layout tweaks --- .../components/Files/FilesTable.tsx | 254 ++++++++++++++++++ apps/publikator/components/Files/Info.js | 32 --- apps/publikator/components/Files/Info.tsx | 44 +++ apps/publikator/components/Files/Row.js | 186 ------------- .../components/Files/{index.js => index.tsx} | 79 +++--- ...omContent.js => extractUrlsFromContent.ts} | 75 ++++-- 6 files changed, 397 insertions(+), 273 deletions(-) create mode 100644 apps/publikator/components/Files/FilesTable.tsx delete mode 100644 apps/publikator/components/Files/Info.js create mode 100644 apps/publikator/components/Files/Info.tsx delete mode 100644 apps/publikator/components/Files/Row.js rename apps/publikator/components/Files/{index.js => index.tsx} (62%) rename apps/publikator/components/Files/utils/{extractUrlsFromContent.js => extractUrlsFromContent.ts} (66%) diff --git a/apps/publikator/components/Files/FilesTable.tsx b/apps/publikator/components/Files/FilesTable.tsx new file mode 100644 index 0000000000..189d6d9e8c --- /dev/null +++ b/apps/publikator/components/Files/FilesTable.tsx @@ -0,0 +1,254 @@ +import { useState } from 'react' +import { css } from 'glamor' + +import { + IconLock, + IconPublic, + IconError, + IconReadTime, + IconLink, +} from '@republik/icons' + +import { + A, + Button, + Label, + IconButton, +} from '@project-r/styleguide' + +import { swissTime } from '../../lib/utils/format' + +import Destroy from './actions/Destroy' +import Publish from './actions/Publish' +import Unpublish from './actions/Unpublish' +import type { FileUsageMap } from './utils/extractUrlsFromContent' + +const timeFormat = swissTime.format('%d. %B %Y, %H:%M Uhr') + +const styles = { + list: css({ + display: 'flex', + flexDirection: 'column', + width: '100%', + }), + row: css({ + display: 'flex', + alignItems: 'flex-start', + width: '100%', + padding: '12px 0', + borderBottom: '1px solid rgba(0, 0, 0, 0.1)', + gap: '24px', + '@media (max-width: 600px)': { + flexWrap: 'wrap', + }, + }), + cellIcon: css({ + flexShrink: 0, + flexGrow: 0, + }), + cellContent: css({ + flex: '1 1 0%', + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: '0.25rem', + }), + cellActions: css({ + flexShrink: 0, + flexGrow: 0, + '@media (max-width: 600px)': { + width: '100%', + marginTop: '8px', + }, + }), + fileName: css({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '100%', + }), + usageInfo: css({ + paddingLeft: '24px', + margin: '4px 0', + }), + actions: css({ + display: 'flex', + gap: '0.5rem', + justifyContent: 'flex-end', + flexWrap: 'wrap', + '@media (max-width: 600px)': { + justifyContent: 'flex-start', + }, + }), +} + +type FileStatus = 'Pending' | 'Failure' | 'Private' | 'Public' + +interface RepoFile { + id: string + name: string + url: string + status: FileStatus + createdAt: string + error?: string + author?: { + name: string + } +} + +interface UrlInfo { + url: string + type: string + text: string +} + +interface StatusConfig { + Icon: React.ComponentType<{ size?: number | string }> + disabled: boolean + colorName: string | undefined + crumb: string | undefined + Action: React.ComponentType<{ + file: RepoFile + isInUse: boolean + usages: UrlInfo[] | undefined + }> | undefined +} + +const statusMap: Record = { + Pending: { + Icon: IconReadTime, + disabled: true, + colorName: undefined, + crumb: undefined, + Action: undefined, + }, + Failure: { + Icon: IconError, + disabled: false, + colorName: 'error', + crumb: undefined, + Action: Destroy, + }, + Private: { + Icon: IconLock, + disabled: false, + colorName: undefined, + crumb: 'nicht öffentlich', + Action: Publish, + }, + Public: { + Icon: IconPublic, + disabled: false, + colorName: 'primary', + crumb: 'öffentlich', + Action: Unpublish, + }, +} + +interface UsageInfoProps { + usages: UrlInfo[] | undefined +} + +const UsageInfo: React.FC = ({ usages }) => { + if (!usages || usages.length === 0) return null + + return ( +
    + {usages.map((usage) => ( +
  • + + +
  • + ))} +
+ ) +} + +interface FileRowProps { + file: RepoFile + usages: UrlInfo[] | undefined +} + +const FileRow: React.FC = ({ file, usages }) => { + const { Icon, disabled, crumb, Action } = + statusMap[file.status] || statusMap.Pending + + const isInUse = usages && usages.length > 0 + + const [copied, setCopied] = useState(false) + + const onClick = async () => { + try { + await navigator.clipboard.writeText(file.url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy link:', err) + } + } + + return ( +
+
+ +
+
+ {disabled ? ( + + {file.name} + + ) : ( + + {file.name} + + )} + + +
+
+
+ + {Action && } +
+
+
+ ) +} + +interface FilesTableProps { + files: RepoFile[] + fileUsageMap: FileUsageMap +} + +const FilesTable: React.FC = ({ files, fileUsageMap }) => { + return ( +
+ {files.map((file) => ( + + ))} +
+ ) +} + +export default FilesTable +export type { RepoFile, UrlInfo } diff --git a/apps/publikator/components/Files/Info.js b/apps/publikator/components/Files/Info.js deleted file mode 100644 index 10953cb019..0000000000 --- a/apps/publikator/components/Files/Info.js +++ /dev/null @@ -1,32 +0,0 @@ -import { css } from 'glamor' -import { IconWarning } from '@republik/icons' - -import { useColorContext } from '@project-r/styleguide' - -const styles = { - container: css({ - margin: '1rem 0 1rem 0', - padding: '1rem', - }), -} - -const Info = () => { - const [colorScheme] = useColorContext() - - return ( -
- {' '} - Keine sensiblen Dateien hochladen -
- ) -} - -export default Info diff --git a/apps/publikator/components/Files/Info.tsx b/apps/publikator/components/Files/Info.tsx new file mode 100644 index 0000000000..0bb6f71648 --- /dev/null +++ b/apps/publikator/components/Files/Info.tsx @@ -0,0 +1,44 @@ +import { css } from 'glamor' +import { IconWarning } from '@republik/icons' + +import { useColorContext } from '@project-r/styleguide' + +const styles = { + container: css({ + margin: '1rem 0 1rem 0', + padding: '1rem', + lineHeight: '1.5', + fontSize: '0.75rem', + }), +} + +const Info: React.FC = () => { + const [colorScheme] = useColorContext() + + return ( +

+ Dateien werden beim hochladen zunächst auf einem privaten Server bei + Amazon AWS gespeichert. Sie werden erst öffentlich zugänglich, wenn Sie + via den "Veröffentlichen"-Button veröffentlicht werden. . Für + die Vorschau der Dateien wird ein temporärer öffentlicher Link erstellt. + Wenn dieser im Dokument verwendet wird, kann das Dokument nicht publiziert + werden.
+ {' '} + + Es dürfen keine sensiblen Dateien (z.B. Whistleblowing-Daten, + Nutzerdaten, Dokumente mit Passwörtern oder Kontaktdaten) hier + hochgeladen werden. + +

+ ) +} + +export default Info diff --git a/apps/publikator/components/Files/Row.js b/apps/publikator/components/Files/Row.js deleted file mode 100644 index 06d921d08f..0000000000 --- a/apps/publikator/components/Files/Row.js +++ /dev/null @@ -1,186 +0,0 @@ -import { useState } from 'react' -import { css } from 'glamor' - -import { - IconLock, - IconPublic, - IconError, - IconReadTime, - IconLink, -} from '@republik/icons' - -import { A, Button, Label, useColorContext } from '@project-r/styleguide' - -import { swissTime } from '../../lib/utils/format' - -import { Tr, Td } from '../Table' - -import Destroy from './actions/Destroy' -import Publish from './actions/Publish' -import Unpublish from './actions/Unpublish' - -const timeFormat = swissTime.format('%d. %B %Y, %H:%M Uhr') - -const styles = { - fileRow: css({ - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - }), - fileName: css({ - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - maxWidth: '400px', - }), - label: css({ - marginLeft: '1.5rem', - }), - usageInfo: css({ - marginLeft: '1.5rem', - marginTop: '0.25rem', - display: 'flex', - alignItems: 'center', - gap: '0.25rem', - }), - actions: css({ - display: 'flex', - gap: '0.5rem', - justifyContent: 'flex-end', - }), -} - -const statusMap = { - Pending: { - Icon: IconReadTime, - disabled: true, - colorName: undefined, - crumb: undefined, - Action: undefined, - }, - Failure: { - Icon: IconError, - disabled: false, - colorName: 'error', - crumb: undefined, - Action: Destroy, - }, - Private: { - Icon: IconLock, - disabled: false, - colorName: undefined, - crumb: 'nicht öffentlich', - Action: Publish, - }, - Public: { - Icon: IconPublic, - disabled: false, - colorName: 'primary', - crumb: undefined, - Action: Unpublish, - }, -} - -const UsageTypeLabels = { - link: 'Link', - image: 'Bild', - embed: 'Embed', - meta: 'Metadaten', -} - -const UsageInfo = ({ usages }) => { - const [colorScheme] = useColorContext() - - if (!usages || usages.length === 0) return null - - // Group usages by type and show summary - const usageText = usages - .map((u) => `${UsageTypeLabels[u.type] || u.type}: "${u.text}"`) - .join(', ') - - return ( -
- - -
- ) -} - -const File = ({ file, usages }) => { - const [colorScheme] = useColorContext() - const { Icon, disabled, colorName, crumb, Action } = - statusMap[file.status] || statusMap.Pending - - const isInUse = usages && usages.length > 0 - - const [copied, setCopied] = useState(false) - - const onClick = async () => { - try { - await navigator.clipboard.writeText(file.url) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy link:', err) - } - } - - return ( - - -
- - {disabled ? ( - - {file.name} - - ) : ( - - {file.name} - - )} -
-
- -
- - - -
- {file.status === 'Public' && ( - - )} - {Action && } -
- - - ) -} - -export default File diff --git a/apps/publikator/components/Files/index.js b/apps/publikator/components/Files/index.tsx similarity index 62% rename from apps/publikator/components/Files/index.js rename to apps/publikator/components/Files/index.tsx index b095d3c9c2..23f81a2265 100644 --- a/apps/publikator/components/Files/index.js +++ b/apps/publikator/components/Files/index.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { gql, useQuery } from '@apollo/client' import { Container } from '@project-r/styleguide' -import { css } from 'glamor' import { RepoFile } from '../../lib/graphql/fragments' import { getRepoIdFromQuery } from '../../lib/repoIdHelper' @@ -9,12 +8,40 @@ import Nav from '../editor/Nav' import Frame from '../Frame' import Loader from '../Loader' -import { Table, Th, Tr } from '../Table' import Info from './Info' -import Row from './Row' +import FilesTable from './FilesTable' import Upload from './Upload' import { getFileUsageInContent } from './utils/extractUrlsFromContent' +import type { RepoFile as RepoFileType } from './FilesTable' + +interface Author { + name: string +} + +interface Document { + id: string + content: unknown +} + +interface Commit { + id: string + document: Document +} + +interface Repo { + id: string + files: RepoFileType[] + latestCommit: Commit +} + +interface GetFilesData { + repo: Repo | null +} + +interface GetFilesVariables { + id: string +} const GET_FILES = gql` query getFiles($id: ID!) { @@ -36,21 +63,26 @@ const GET_FILES = gql` ${RepoFile} ` -const styles = { - container: css({ - overflow: 'scroll', - }), +interface NextRouter { + query: Record +} + +type TFunction = (key: string, options?: Record) => string + +interface FilesPageProps { + router: NextRouter + t: TFunction } -const FilesPage = ({ router, t }) => { +const FilesPage: React.FC = ({ router, t }) => { const repoId = getRepoIdFromQuery(router.query) - const variables = { id: repoId } + const variables: GetFilesVariables = { id: repoId } const { data, loading, error: queryError, - } = useQuery(GET_FILES, { variables }) + } = useQuery(GET_FILES, { variables }) const error = data?.repo === null @@ -81,27 +113,12 @@ const FilesPage = ({ router, t }) => { render={() => ( - - {!!data.repo.files.length && ( -
- - - - - - - - - {data.repo.files.map((file) => ( - - ))} - -
Datei
-
+ + {!!data!.repo!.files.length && ( + )}
)} diff --git a/apps/publikator/components/Files/utils/extractUrlsFromContent.js b/apps/publikator/components/Files/utils/extractUrlsFromContent.ts similarity index 66% rename from apps/publikator/components/Files/utils/extractUrlsFromContent.js rename to apps/publikator/components/Files/utils/extractUrlsFromContent.ts index fca1e9aa0f..c8fd7bebb8 100644 --- a/apps/publikator/components/Files/utils/extractUrlsFromContent.js +++ b/apps/publikator/components/Files/utils/extractUrlsFromContent.ts @@ -1,21 +1,53 @@ import visit from 'unist-util-visit' +import type { Node } from 'unist' + +interface MetaUrlField { + key: string + label: string +} + +interface UrlInfo { + url: string + type: 'meta' | 'link' | 'image' | 'embed' + text: string +} + +interface ContentMeta { + [key: string]: unknown +} + +interface MdastContent extends Node { + meta?: ContentMeta + children?: MdastContent[] + url?: string + alt?: string + title?: string + value?: string + data?: { + url?: string + [key: string]: unknown + } + identifier?: string +} + +interface RepoFile { + url: string + [key: string]: unknown +} /** * Metadata fields that may contain unpublished file URLs */ -const META_URL_FIELDS = [{ key: 'audioSourceMp3', label: 'Audio' }] +const META_URL_FIELDS: MetaUrlField[] = [{ key: 'audioSourceMp3', label: 'Audio' }] /** * Extract all URLs from MDAST content that could reference uploaded files. * This includes links, images, metadata fields (audio, cover image), and other embedded content. - * - * @param {Object} content - The MDAST document content - * @returns {Array<{url: string, type: string, text?: string}>} - Array of found URLs with their context */ -const extractUrlsFromContent = (content) => { +const extractUrlsFromContent = (content: MdastContent | null | undefined): UrlInfo[] => { if (!content) return [] - const urls = [] + const urls: UrlInfo[] = [] // Check metadata fields for URLs (audioSourceMp3, image, etc.) if (content.meta) { @@ -32,7 +64,7 @@ const extractUrlsFromContent = (content) => { } // Visit all link nodes - visit(content, 'link', (node) => { + visit(content, 'link', (node: MdastContent) => { if (node?.url) { urls.push({ url: node.url, @@ -43,7 +75,7 @@ const extractUrlsFromContent = (content) => { }) // Visit all image nodes - visit(content, 'image', (node) => { + visit(content, 'image', (node: MdastContent) => { if (node?.url) { urls.push({ url: node.url, @@ -54,7 +86,7 @@ const extractUrlsFromContent = (content) => { }) // Visit zone nodes (embeds, etc.) that may have URLs in their data - visit(content, 'zone', (node) => { + visit(content, 'zone', (node: MdastContent) => { if (node?.data?.url) { urls.push({ url: node.data.url, @@ -70,7 +102,7 @@ const extractUrlsFromContent = (content) => { /** * Get text content from a node */ -const getNodeText = (node) => { +const getNodeText = (node: MdastContent | null | undefined): string => { if (!node) return '' if (typeof node.value === 'string') return node.value if (Array.isArray(node.children)) { @@ -81,10 +113,8 @@ const getNodeText = (node) => { /** * Normalize a URL for comparison by removing query parameters and trailing slashes - * @param {string} url - The URL to normalize - * @returns {string} - The normalized URL */ -const normalizeUrl = (url) => { +const normalizeUrl = (url: string | null | undefined): string => { if (!url) return '' try { const urlObj = new URL(url) @@ -98,28 +128,26 @@ const normalizeUrl = (url) => { /** * Check if two URLs match (comparing normalized versions) - * @param {string} url1 - First URL - * @param {string} url2 - Second URL - * @returns {boolean} - Whether the URLs match */ -const urlsMatch = (url1, url2) => { +const urlsMatch = (url1: string | null | undefined, url2: string | null | undefined): boolean => { if (!url1 || !url2) return false const norm1 = normalizeUrl(url1) const norm2 = normalizeUrl(url2) return norm1 === norm2 || norm1.includes(norm2) || norm2.includes(norm1) } +export type FileUsageMap = Map + /** * Check which files are being used in the document content. * Returns a Map of file URLs to their usage contexts. - * - * @param {Array} files - Array of file objects with url property - * @param {Object} content - The MDAST document content - * @returns {Map>} - Map of file URLs to usage contexts */ -export const getFileUsageInContent = (files, content) => { +export const getFileUsageInContent = ( + files: RepoFile[] | null | undefined, + content: MdastContent | null | undefined +): FileUsageMap => { const contentUrls = extractUrlsFromContent(content) - const fileUsage = new Map() + const fileUsage: FileUsageMap = new Map() if (!files || !contentUrls.length) { return fileUsage @@ -140,4 +168,3 @@ export const getFileUsageInContent = (files, content) => { } export default extractUrlsFromContent -