Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions cdd7ca7626 chore: bump version to v0.4.22
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-01 20:52:47 +00:00
Matthieu e1bf9ecb22 fix(frontend) : copie presse-papiers fonctionnelle en HTTP via fallback execCommand
Auto Tag Develop / tag (push) Successful in 7s
navigator.clipboard n'est disponible qu'en secure context (HTTPS/localhost),
ce qui cassait la copie en prod HTTP. Ajout d'un utilitaire copyToClipboard
avec fallback textarea + execCommand, appliqué au viewer Markdown, au token
API du profil et au nom de branche Git.
2026-06-01 22:52:32 +02:00
gitea-actions 85897708ec chore: bump version to v0.4.21
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 55s
2026-06-01 20:45:31 +00:00
Matthieu 46c27aab42 feat(documents) : viewer Markdown des documents de ticket avec copie en un clic
Auto Tag Develop / tag (push) Successful in 10s
Aperçu du contenu source pour les fichiers texte/Markdown (.md, .txt, .csv, .json, .xml) avec bouton Copier (presse-papier + toast) et téléchargement. Détection par MIME ou extension, chargement via getContent. Icône Markdown dédiée dans la liste.
2026-06-01 22:45:21 +02:00
8 changed files with 139 additions and 13 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.20' app.version: '0.4.22'
@@ -68,6 +68,7 @@ function isImage(mimeType: string): boolean {
} }
function getIconForMime(mimeType: string): string { function getIconForMime(mimeType: string): string {
if (mimeType === 'text/markdown') return 'mdi:language-markdown'
if (mimeType === 'application/pdf') return 'heroicons:document-text' if (mimeType === 'application/pdf') return 'heroicons:document-text'
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells' if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document' if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
@@ -58,6 +58,46 @@
class="h-[85vh] w-[80vw] rounded-lg bg-white" class="h-[85vh] w-[80vw] rounded-lg bg-white"
/> />
<!-- Text / Markdown preview -->
<div
v-else-if="isText"
class="flex max-h-[85vh] w-[85vw] max-w-3xl flex-col overflow-hidden rounded-xl bg-white"
>
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
<p class="truncate text-sm font-medium text-neutral-700">{{ document.originalName }}</p>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg bg-neutral-100 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200"
@click="copyContent"
>
<Icon
:name="copied ? 'heroicons:check' : 'mdi:content-copy'"
class="h-4 w-4"
:class="copied ? 'text-green-600' : ''"
/>
{{ copied ? $t('taskDocuments.copied') : $t('taskDocuments.copy') }}
</button>
<a
:href="downloadUrl"
download
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('taskDocuments.download') }}
</a>
</div>
</div>
<div class="overflow-auto p-4">
<div v-if="loadingText" class="flex justify-center py-10">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<pre
v-else
class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-neutral-800"
>{{ textContent }}</pre>
</div>
</div>
<!-- Generic file --> <!-- Generic file -->
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10"> <div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" /> <Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
@@ -73,7 +113,7 @@
</div> </div>
<!-- File name footer --> <!-- File name footer -->
<p class="mt-3 text-sm text-white/70">{{ document.originalName }}</p> <p v-if="!isText" class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -84,6 +124,7 @@
import type { TaskDocument } from '~/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard'
const props = defineProps<{ const props = defineProps<{
document: TaskDocument | null document: TaskDocument | null
@@ -98,19 +139,53 @@ defineEmits<{
}>() }>()
const overlayRef = ref<HTMLElement | null>(null) const overlayRef = ref<HTMLElement | null>(null)
const textContent = ref('')
const loadingText = ref(false)
const copied = ref(false)
const { getDownloadUrl } = useTaskDocumentService() const { getDownloadUrl, getContent } = useTaskDocumentService()
const { t } = useI18n()
const TEXT_MIME_TYPES = ['text/markdown', 'text/plain', 'text/csv', 'application/json', 'application/xml', 'text/xml']
function isTextDocument(doc: TaskDocument | null): boolean {
if (!doc) return false
if (TEXT_MIME_TYPES.includes(doc.mimeType)) return true
return /\.(md|markdown|txt|csv|json|xml)$/i.test(doc.originalName)
}
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '') const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false) const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf') const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
const isText = computed(() => isTextDocument(props.document))
// Focus overlay for keyboard events async function copyContent() {
watch(() => props.document, (doc) => { if (await copyToClipboard(textContent.value)) {
if (doc) { copied.value = true
nextTick(() => overlayRef.value?.focus()) useToast().success(t('taskDocuments.copied'))
setTimeout(() => { copied.value = false }, 2000)
} }
}) }
// Focus overlay for keyboard events, and load text content for text/markdown documents
watch(() => props.document, async (doc) => {
textContent.value = ''
copied.value = false
if (!doc) return
nextTick(() => overlayRef.value?.focus())
if (isTextDocument(doc)) {
loadingText.value = true
try {
textContent.value = await getContent(doc.id)
} catch {
textContent.value = ''
} finally {
loadingText.value = false
}
}
}, { immediate: true })
</script> </script>
<style scoped> <style scoped>
+2 -1
View File
@@ -229,6 +229,7 @@
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea' import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
@@ -374,7 +375,7 @@ async function handleCreate() {
async function handleCopy() { async function handleCopy() {
try { try {
const result = await getBranchName(props.task.id, branchForm.type) const result = await getBranchName(props.task.id, branchForm.type)
await navigator.clipboard.writeText(result.name) await copyToClipboard(result.name)
const { success } = useToast() const { success } = useToast()
success(t('gitea.branch.copied')) success(t('gitea.branch.copied'))
} catch { } catch {
+2
View File
@@ -126,6 +126,8 @@
"confirmDeleteTitle": "Supprimer le document", "confirmDeleteTitle": "Supprimer le document",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?", "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?",
"download": "Télécharger", "download": "Télécharger",
"copy": "Copier",
"copied": "Contenu copié !",
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo." "maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
}, },
"tasks": { "tasks": {
+3 -3
View File
@@ -129,6 +129,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService' import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token' import { useApiTokenService } from '~/services/api-token'
import { copyToClipboard } from '~/utils/clipboard'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
@@ -181,10 +182,9 @@ async function onRemove() {
async function onCopy() { async function onCopy() {
if (!auth.user?.apiToken) return if (!auth.user?.apiToken) return
try { if (await copyToClipboard(auth.user.apiToken)) {
await navigator.clipboard.writeText(auth.user.apiToken)
toast.success({ message: t('profile.apiToken.copied') }) toast.success({ message: t('profile.apiToken.copied') })
} catch { } else {
toast.error({ message: t('profile.apiToken.copyFailed') }) toast.error({ message: t('profile.apiToken.copyFailed') })
} }
} }
+8 -1
View File
@@ -41,5 +41,12 @@ export function useTaskDocumentService() {
return `${baseURL}/task_documents/${id}/download` return `${baseURL}/task_documents/${id}/download`
} }
return { getByTask, upload, remove, getDownloadUrl } async function getContent(id: number): Promise<string> {
return $fetch<string>(`${baseURL}/task_documents/${id}/download`, {
credentials: 'include',
responseType: 'text',
})
}
return { getByTask, upload, remove, getDownloadUrl, getContent }
} }
+40
View File
@@ -0,0 +1,40 @@
/**
* Copy text to the clipboard with a fallback for non-secure contexts.
*
* `navigator.clipboard` is only available in secure contexts (HTTPS or
* localhost). On a plain HTTP origin (e.g. an internal/prod server without
* TLS) the API is missing, so we fall back to the legacy
* `document.execCommand('copy')` using a temporary off-screen textarea.
*
* @returns `true` if the copy succeeded, `false` otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Preferred path: available in secure contexts (HTTPS / localhost).
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the legacy fallback below.
}
}
// Legacy fallback: works on plain HTTP origins.
try {
const textarea = document.createElement('textarea')
textarea.value = text
// Keep it out of view and prevent layout shift / scrolling.
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.left = '-9999px'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}