feat: add feedback submission flow
This commit is contained in:
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@vueuse/head": "^2.0.0",
|
"@vueuse/head": "^2.0.0",
|
||||||
"@xtiannyeto/vue-auth-social": "^0.1.9",
|
"@xtiannyeto/vue-auth-social": "^0.1.9",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"i18n": "^0.15.1",
|
"i18n": "^0.15.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
@@ -2161,6 +2162,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -2497,6 +2507,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -3565,6 +3584,19 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
@@ -5341,6 +5373,15 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/text-table": {
|
"node_modules/text-table": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
@@ -5539,6 +5580,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@vueuse/head": "^2.0.0",
|
"@vueuse/head": "^2.0.0",
|
||||||
"@xtiannyeto/vue-auth-social": "^0.1.9",
|
"@xtiannyeto/vue-auth-social": "^0.1.9",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"i18n": "^0.15.1",
|
"i18n": "^0.15.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FeedbackFloatingButton v-if="showsAppSidebar" />
|
||||||
</div>
|
</div>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
import AppBar from '@/layouts/main/AppBar.vue';
|
import AppBar from '@/layouts/main/AppBar.vue';
|
||||||
import AppSidebar from '@/layouts/main/AppSidebar.vue';
|
import AppSidebar from '@/layouts/main/AppSidebar.vue';
|
||||||
|
import FeedbackFloatingButton from '@/features/feedback/components/FeedbackFloatingButton.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup>
|
||||||
|
import { defineAsyncComponent, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { mdiMessageAlertOutline } from '@mdi/js';
|
||||||
|
|
||||||
|
const FeedbackSubmissionDialog = defineAsyncComponent(() => import('./FeedbackSubmissionDialog.vue'));
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const isDialogOpen = ref(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="feedback-entry"
|
||||||
|
data-feedback-ui="true"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="feedback-entry-button"
|
||||||
|
type="button"
|
||||||
|
:title="t('feedback.open')"
|
||||||
|
@click="isDialogOpen = true"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiMessageAlertOutline" />
|
||||||
|
<span>{{ t('feedback.button') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FeedbackSubmissionDialog v-model="isDialogOpen" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback-entry {
|
||||||
|
@apply fixed bottom-5 right-5 z-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-entry-button {
|
||||||
|
@apply flex h-12 items-center gap-2 rounded-full border px-4 text-sm font-bold shadow-lg transition-colors;
|
||||||
|
background: #172033;
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
color: #fffaf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-entry-button:hover {
|
||||||
|
background: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-entry-button span {
|
||||||
|
@apply hidden sm:inline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,711 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
import { useToast } from 'vue-toastification';
|
||||||
|
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||||
|
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
|
||||||
|
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||||
|
import { useFeedbackSubmissionStore } from '@/features/feedback/stores/feedbackSubmissionStore.js';
|
||||||
|
import { useProjectsStore } from '@/features/projects/stores/projectsStore.js';
|
||||||
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
|
import {
|
||||||
|
mdiArrowTopRight,
|
||||||
|
mdiCameraOutline,
|
||||||
|
mdiClose,
|
||||||
|
mdiContentSaveOutline,
|
||||||
|
mdiCrop,
|
||||||
|
mdiEraser,
|
||||||
|
mdiFormatText,
|
||||||
|
mdiGesture,
|
||||||
|
mdiMinus,
|
||||||
|
mdiRedoVariant,
|
||||||
|
mdiShapeOvalPlus,
|
||||||
|
mdiUndoVariant,
|
||||||
|
} from '@mdi/js';
|
||||||
|
|
||||||
|
const model = defineModel({ type: Boolean, default: false });
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const toast = useToast();
|
||||||
|
const clientsStore = useClientsStore();
|
||||||
|
const contentItemsStore = useContentItemsStore();
|
||||||
|
const contentItemDetailStore = useContentItemDetailStore();
|
||||||
|
const feedbackStore = useFeedbackSubmissionStore();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
type: null,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
const editorCanvas = ref(null);
|
||||||
|
const sourceImage = ref(null);
|
||||||
|
const imageElement = ref(null);
|
||||||
|
const screenshotBlob = ref(null);
|
||||||
|
const selectedTool = ref('freehand');
|
||||||
|
const captureError = ref(null);
|
||||||
|
const isCapturing = ref(false);
|
||||||
|
const isDrawing = ref(false);
|
||||||
|
const startPoint = ref(null);
|
||||||
|
const draftPoint = ref(null);
|
||||||
|
const freehandPoints = ref([]);
|
||||||
|
const annotations = ref([]);
|
||||||
|
const imageHistory = ref([]);
|
||||||
|
|
||||||
|
const feedbackTypes = computed(() => [
|
||||||
|
{ title: t('feedback.types.bug'), value: 'Bug' },
|
||||||
|
{ title: t('feedback.types.suggestion'), value: 'Suggestion' },
|
||||||
|
{ title: t('feedback.types.request'), value: 'Request' },
|
||||||
|
]);
|
||||||
|
const annotationTools = computed(() => [
|
||||||
|
{ value: 'crop', label: t('feedback.tools.crop'), icon: mdiCrop },
|
||||||
|
{ value: 'arrow', label: t('feedback.tools.arrow'), icon: mdiArrowTopRight },
|
||||||
|
{ value: 'ellipse', label: t('feedback.tools.ellipse'), icon: mdiShapeOvalPlus },
|
||||||
|
{ value: 'line', label: t('feedback.tools.line'), icon: mdiMinus },
|
||||||
|
{ value: 'freehand', label: t('feedback.tools.freehand'), icon: mdiGesture },
|
||||||
|
{ value: 'text', label: t('feedback.tools.text'), icon: mdiFormatText },
|
||||||
|
]);
|
||||||
|
const isDirty = computed(() =>
|
||||||
|
Boolean(form.type || form.description.trim() || sourceImage.value || screenshotBlob.value)
|
||||||
|
);
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
Boolean(form.type && form.description.trim()) && !feedbackStore.isSubmitting
|
||||||
|
);
|
||||||
|
const currentContentItem = computed(() => {
|
||||||
|
const routeId = route.params.id;
|
||||||
|
if (!routeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentItemDetailStore.item?.id === routeId
|
||||||
|
? contentItemDetailStore.item
|
||||||
|
: contentItemsStore.items.find(item => item.id === routeId) ?? null;
|
||||||
|
});
|
||||||
|
const currentProject = computed(() => {
|
||||||
|
const projectId = route.params.projectId ?? currentContentItem.value?.projectId;
|
||||||
|
if (!projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectsStore.projects.find(project => project.id === projectId) ?? null;
|
||||||
|
});
|
||||||
|
const currentClient = computed(() => {
|
||||||
|
const clientId = route.query.clientId ?? currentProject.value?.clientId ?? currentContentItem.value?.clientId;
|
||||||
|
if (!clientId) {
|
||||||
|
return clientsStore.operationalClient ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientsStore.clients.find(client => client.id === clientId) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(model, value => {
|
||||||
|
if (value) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.type = null;
|
||||||
|
form.description = '';
|
||||||
|
sourceImage.value = null;
|
||||||
|
imageElement.value = null;
|
||||||
|
screenshotBlob.value = null;
|
||||||
|
captureError.value = null;
|
||||||
|
selectedTool.value = 'freehand';
|
||||||
|
annotations.value = [];
|
||||||
|
imageHistory.value = [];
|
||||||
|
clearPointerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestClose() {
|
||||||
|
if (isDirty.value && !window.confirm(t('feedback.discardConfirm'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.value = false;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureViewport() {
|
||||||
|
isCapturing.value = true;
|
||||||
|
captureError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nextTick();
|
||||||
|
const target = document.querySelector('.shell-container') ?? document.body;
|
||||||
|
const canvas = await html2canvas(target, {
|
||||||
|
backgroundColor: '#fffaf2',
|
||||||
|
height: window.innerHeight,
|
||||||
|
ignoreElements: element => element.dataset?.feedbackUi === 'true',
|
||||||
|
scale: Math.min(window.devicePixelRatio || 1, 2),
|
||||||
|
scrollX: -window.scrollX,
|
||||||
|
scrollY: -window.scrollY,
|
||||||
|
useCORS: true,
|
||||||
|
width: window.innerWidth,
|
||||||
|
windowHeight: window.innerHeight,
|
||||||
|
windowWidth: window.innerWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceImage.value = canvas.toDataURL('image/jpeg', 0.88);
|
||||||
|
imageHistory.value = [sourceImage.value];
|
||||||
|
annotations.value = [];
|
||||||
|
await loadImage();
|
||||||
|
await exportScreenshot();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to capture feedback screenshot:', error);
|
||||||
|
captureError.value = t('feedback.captureFailed');
|
||||||
|
} finally {
|
||||||
|
isCapturing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!sourceImage.value) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
imageElement.value = image;
|
||||||
|
nextTick(() => {
|
||||||
|
redrawCanvas();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
image.src = sourceImage.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasPoint(event) {
|
||||||
|
const canvas = editorCanvas.value;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const clientX = event.touches?.[0]?.clientX ?? event.clientX;
|
||||||
|
const clientY = event.touches?.[0]?.clientY ?? event.clientY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: ((clientX - rect.left) / rect.width) * canvas.width,
|
||||||
|
y: ((clientY - rect.top) / rect.height) * canvas.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginAnnotation(event) {
|
||||||
|
if (!imageElement.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = getCanvasPoint(event);
|
||||||
|
if (selectedTool.value === 'text') {
|
||||||
|
const text = window.prompt(t('feedback.textPrompt'));
|
||||||
|
if (text?.trim()) {
|
||||||
|
annotations.value.push({ tool: 'text', x: point.x, y: point.y, text: text.trim() });
|
||||||
|
redrawCanvas();
|
||||||
|
exportScreenshot();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDrawing.value = true;
|
||||||
|
startPoint.value = point;
|
||||||
|
draftPoint.value = point;
|
||||||
|
freehandPoints.value = [point];
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveAnnotation(event) {
|
||||||
|
if (!isDrawing.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = getCanvasPoint(event);
|
||||||
|
draftPoint.value = point;
|
||||||
|
|
||||||
|
if (selectedTool.value === 'freehand') {
|
||||||
|
freehandPoints.value.push(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
redrawCanvas(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endAnnotation() {
|
||||||
|
if (!isDrawing.value || !startPoint.value || !draftPoint.value) {
|
||||||
|
clearPointerState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const annotation = selectedTool.value === 'freehand'
|
||||||
|
? { tool: 'freehand', points: [...freehandPoints.value] }
|
||||||
|
: {
|
||||||
|
tool: selectedTool.value,
|
||||||
|
x1: startPoint.value.x,
|
||||||
|
y1: startPoint.value.y,
|
||||||
|
x2: draftPoint.value.x,
|
||||||
|
y2: draftPoint.value.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedTool.value === 'crop') {
|
||||||
|
await applyCrop(annotation);
|
||||||
|
} else if (hasMeaningfulSize(annotation)) {
|
||||||
|
annotations.value.push(annotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPointerState();
|
||||||
|
redrawCanvas();
|
||||||
|
await exportScreenshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulSize(annotation) {
|
||||||
|
if (annotation.tool === 'freehand') {
|
||||||
|
return annotation.points.length > 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.abs(annotation.x2 - annotation.x1) > 6 || Math.abs(annotation.y2 - annotation.y1) > 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCrop(crop) {
|
||||||
|
const canvas = editorCanvas.value;
|
||||||
|
const x = Math.max(0, Math.min(crop.x1, crop.x2));
|
||||||
|
const y = Math.max(0, Math.min(crop.y1, crop.y2));
|
||||||
|
const width = Math.min(canvas.width - x, Math.abs(crop.x2 - crop.x1));
|
||||||
|
const height = Math.min(canvas.height - y, Math.abs(crop.y2 - crop.y1));
|
||||||
|
|
||||||
|
if (width < 20 || height < 20) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cropCanvas = document.createElement('canvas');
|
||||||
|
cropCanvas.width = width;
|
||||||
|
cropCanvas.height = height;
|
||||||
|
cropCanvas.getContext('2d').drawImage(canvas, x, y, width, height, 0, 0, width, height);
|
||||||
|
sourceImage.value = cropCanvas.toDataURL('image/jpeg', 0.9);
|
||||||
|
imageHistory.value.push(sourceImage.value);
|
||||||
|
annotations.value = [];
|
||||||
|
await loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPointerState() {
|
||||||
|
isDrawing.value = false;
|
||||||
|
startPoint.value = null;
|
||||||
|
draftPoint.value = null;
|
||||||
|
freehandPoints.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function redrawCanvas(includeDraft = false) {
|
||||||
|
const canvas = editorCanvas.value;
|
||||||
|
const image = imageElement.value;
|
||||||
|
if (!canvas || !image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = image.naturalWidth;
|
||||||
|
canvas.height = image.naturalHeight;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.drawImage(image, 0, 0);
|
||||||
|
|
||||||
|
annotations.value.forEach(annotation => drawAnnotation(context, annotation));
|
||||||
|
|
||||||
|
if (includeDraft && startPoint.value && draftPoint.value) {
|
||||||
|
const draft = selectedTool.value === 'freehand'
|
||||||
|
? { tool: 'freehand', points: freehandPoints.value }
|
||||||
|
: { tool: selectedTool.value, x1: startPoint.value.x, y1: startPoint.value.y, x2: draftPoint.value.x, y2: draftPoint.value.y };
|
||||||
|
drawAnnotation(context, draft, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAnnotation(context, annotation, isDraft = false) {
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = annotation.tool === 'crop' ? '#0f766e' : '#ef4444';
|
||||||
|
context.fillStyle = '#ef4444';
|
||||||
|
context.lineWidth = Math.max(4, editorCanvas.value.width / 320);
|
||||||
|
context.lineCap = 'round';
|
||||||
|
context.lineJoin = 'round';
|
||||||
|
context.globalAlpha = isDraft ? 0.78 : 1;
|
||||||
|
|
||||||
|
if (annotation.tool === 'line' || annotation.tool === 'arrow') {
|
||||||
|
drawLine(context, annotation.x1, annotation.y1, annotation.x2, annotation.y2, annotation.tool === 'arrow');
|
||||||
|
} else if (annotation.tool === 'ellipse') {
|
||||||
|
context.beginPath();
|
||||||
|
context.ellipse(
|
||||||
|
(annotation.x1 + annotation.x2) / 2,
|
||||||
|
(annotation.y1 + annotation.y2) / 2,
|
||||||
|
Math.abs(annotation.x2 - annotation.x1) / 2,
|
||||||
|
Math.abs(annotation.y2 - annotation.y1) / 2,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Math.PI * 2
|
||||||
|
);
|
||||||
|
context.stroke();
|
||||||
|
} else if (annotation.tool === 'crop') {
|
||||||
|
context.setLineDash([12, 8]);
|
||||||
|
context.strokeRect(annotation.x1, annotation.y1, annotation.x2 - annotation.x1, annotation.y2 - annotation.y1);
|
||||||
|
} else if (annotation.tool === 'freehand') {
|
||||||
|
context.beginPath();
|
||||||
|
annotation.points.forEach((point, index) => {
|
||||||
|
if (index === 0) {
|
||||||
|
context.moveTo(point.x, point.y);
|
||||||
|
} else {
|
||||||
|
context.lineTo(point.x, point.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
context.stroke();
|
||||||
|
} else if (annotation.tool === 'text') {
|
||||||
|
context.font = `${Math.max(24, editorCanvas.value.width / 32)}px sans-serif`;
|
||||||
|
context.lineWidth = 6;
|
||||||
|
context.strokeStyle = '#fffaf2';
|
||||||
|
context.strokeText(annotation.text, annotation.x, annotation.y);
|
||||||
|
context.fillText(annotation.text, annotation.x, annotation.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLine(context, x1, y1, x2, y2, withArrow) {
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x1, y1);
|
||||||
|
context.lineTo(x2, y2);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
if (!withArrow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||||
|
const size = Math.max(18, editorCanvas.value.width / 48);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x2, y2);
|
||||||
|
context.lineTo(x2 - size * Math.cos(angle - Math.PI / 6), y2 - size * Math.sin(angle - Math.PI / 6));
|
||||||
|
context.lineTo(x2 - size * Math.cos(angle + Math.PI / 6), y2 - size * Math.sin(angle + Math.PI / 6));
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undoAnnotation() {
|
||||||
|
if (annotations.value.length) {
|
||||||
|
annotations.value.pop();
|
||||||
|
} else if (imageHistory.value.length > 1) {
|
||||||
|
imageHistory.value.pop();
|
||||||
|
sourceImage.value = imageHistory.value[imageHistory.value.length - 1];
|
||||||
|
await loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
redrawCanvas();
|
||||||
|
await exportScreenshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAnnotations() {
|
||||||
|
if (!sourceImage.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
annotations.value = [];
|
||||||
|
if (imageHistory.value.length > 1) {
|
||||||
|
sourceImage.value = imageHistory.value[0];
|
||||||
|
imageHistory.value = [sourceImage.value];
|
||||||
|
await loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
redrawCanvas();
|
||||||
|
await exportScreenshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScreenshot() {
|
||||||
|
sourceImage.value = null;
|
||||||
|
imageElement.value = null;
|
||||||
|
screenshotBlob.value = null;
|
||||||
|
annotations.value = [];
|
||||||
|
imageHistory.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportScreenshot() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const canvas = editorCanvas.value;
|
||||||
|
if (!canvas) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
screenshotBlob.value = blob;
|
||||||
|
resolve();
|
||||||
|
}, 'image/jpeg', 0.86);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMetadata() {
|
||||||
|
return {
|
||||||
|
type: form.type,
|
||||||
|
description: form.description.trim(),
|
||||||
|
submittedPath: route.fullPath,
|
||||||
|
browserUserAgent: navigator.userAgent,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
appVersion: import.meta.env.VITE_APP_VERSION ?? null,
|
||||||
|
workspaceId: workspaceStore.activeWorkspace?.id ?? null,
|
||||||
|
workspaceName: workspaceStore.activeWorkspace?.name ?? null,
|
||||||
|
clientId: currentClient.value?.id ?? null,
|
||||||
|
clientName: currentClient.value?.name ?? null,
|
||||||
|
projectId: currentProject.value?.id ?? null,
|
||||||
|
projectName: currentProject.value?.name ?? null,
|
||||||
|
contentItemId: currentContentItem.value?.id ?? null,
|
||||||
|
contentItemTitle: currentContentItem.value?.title ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportScreenshot();
|
||||||
|
await feedbackStore.submitFeedback(buildMetadata(), screenshotBlob.value);
|
||||||
|
toast.success(t('feedback.submitted'));
|
||||||
|
model.value = false;
|
||||||
|
resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t('feedback.submitFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
:model-value="model"
|
||||||
|
max-width="980"
|
||||||
|
persistent
|
||||||
|
data-feedback-ui="true"
|
||||||
|
>
|
||||||
|
<section class="feedback-dialog">
|
||||||
|
<header class="feedback-dialog-header">
|
||||||
|
<div>
|
||||||
|
<p>{{ t('feedback.eyebrow') }}</p>
|
||||||
|
<h2>{{ t('feedback.title') }}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="feedback-icon-button"
|
||||||
|
type="button"
|
||||||
|
:title="t('close')"
|
||||||
|
@click="requestClose"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiClose" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="feedback-dialog-body">
|
||||||
|
<div class="feedback-form">
|
||||||
|
<v-select
|
||||||
|
v-model="form.type"
|
||||||
|
:items="feedbackTypes"
|
||||||
|
:label="t('feedback.fields.type')"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
/>
|
||||||
|
<v-textarea
|
||||||
|
v-model="form.description"
|
||||||
|
:label="t('feedback.fields.description')"
|
||||||
|
:placeholder="t('feedback.fields.descriptionPlaceholder')"
|
||||||
|
variant="outlined"
|
||||||
|
rows="7"
|
||||||
|
auto-grow
|
||||||
|
counter="8000"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="captureError"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
{{ captureError }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<div class="feedback-actions">
|
||||||
|
<v-btn
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
:loading="isCapturing"
|
||||||
|
:prepend-icon="mdiCameraOutline"
|
||||||
|
@click="captureViewport"
|
||||||
|
>
|
||||||
|
{{ t('feedback.capture') }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="sourceImage"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
:prepend-icon="mdiEraser"
|
||||||
|
@click="removeScreenshot"
|
||||||
|
>
|
||||||
|
{{ t('feedback.removeCapture') }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sourceImage"
|
||||||
|
class="feedback-editor"
|
||||||
|
>
|
||||||
|
<div class="feedback-toolstrip">
|
||||||
|
<button
|
||||||
|
v-for="tool in annotationTools"
|
||||||
|
:key="tool.value"
|
||||||
|
class="feedback-tool-button"
|
||||||
|
:class="{ 'feedback-tool-button-active': selectedTool === tool.value }"
|
||||||
|
type="button"
|
||||||
|
:title="tool.label"
|
||||||
|
@click="selectedTool = tool.value"
|
||||||
|
>
|
||||||
|
<v-icon :icon="tool.icon" />
|
||||||
|
</button>
|
||||||
|
<span class="feedback-tool-divider"></span>
|
||||||
|
<button
|
||||||
|
class="feedback-tool-button"
|
||||||
|
type="button"
|
||||||
|
:title="t('feedback.tools.undo')"
|
||||||
|
@click="undoAnnotation"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiUndoVariant" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="feedback-tool-button"
|
||||||
|
type="button"
|
||||||
|
:title="t('feedback.tools.clear')"
|
||||||
|
@click="clearAnnotations"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiRedoVariant" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
ref="editorCanvas"
|
||||||
|
class="feedback-canvas"
|
||||||
|
@pointerdown="beginAnnotation"
|
||||||
|
@pointermove="moveAnnotation"
|
||||||
|
@pointerup="endAnnotation"
|
||||||
|
@pointerleave="endAnnotation"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="feedback-empty-preview"
|
||||||
|
>
|
||||||
|
<v-icon :icon="mdiCameraOutline" />
|
||||||
|
<span>{{ t('feedback.noCapture') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="feedback-dialog-footer">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="requestClose"
|
||||||
|
>
|
||||||
|
{{ t('cancel') }}
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="feedbackStore.isSubmitting"
|
||||||
|
:prepend-icon="mdiContentSaveOutline"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ t('feedback.submit') }}
|
||||||
|
</v-btn>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback-dialog {
|
||||||
|
@apply overflow-hidden rounded-lg border;
|
||||||
|
background: #fffaf2;
|
||||||
|
border-color: rgba(23, 32, 51, 0.12);
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-dialog-header,
|
||||||
|
.feedback-dialog-footer {
|
||||||
|
@apply flex items-center justify-between gap-4 px-5 py-4;
|
||||||
|
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-dialog-footer {
|
||||||
|
@apply justify-end;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-top: 1px solid rgba(23, 32, 51, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-dialog-header p {
|
||||||
|
@apply text-xs font-black uppercase;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-dialog-header h2 {
|
||||||
|
@apply text-xl font-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-icon-button,
|
||||||
|
.feedback-tool-button {
|
||||||
|
@apply flex h-10 w-10 items-center justify-center rounded-full transition-colors;
|
||||||
|
background: rgba(23, 32, 51, 0.06);
|
||||||
|
color: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-icon-button:hover,
|
||||||
|
.feedback-tool-button:hover,
|
||||||
|
.feedback-tool-button-active {
|
||||||
|
background: #172033;
|
||||||
|
color: #fffaf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-dialog-body {
|
||||||
|
@apply grid gap-5 p-5 lg:grid-cols-[minmax(18rem,22rem)_1fr];
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form,
|
||||||
|
.feedback-editor,
|
||||||
|
.feedback-empty-preview {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-actions {
|
||||||
|
@apply flex flex-wrap items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-editor {
|
||||||
|
@apply flex flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-toolstrip {
|
||||||
|
@apply flex flex-wrap items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-tool-divider {
|
||||||
|
@apply h-7 w-px;
|
||||||
|
background: rgba(23, 32, 51, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-canvas {
|
||||||
|
@apply block w-full rounded-md border;
|
||||||
|
max-height: 58vh;
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: rgba(23, 32, 51, 0.12);
|
||||||
|
cursor: crosshair;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-empty-preview {
|
||||||
|
@apply flex min-h-[18rem] flex-col items-center justify-center gap-3 rounded-md border border-dashed text-sm;
|
||||||
|
background: rgba(23, 32, 51, 0.03);
|
||||||
|
border-color: rgba(23, 32, 51, 0.16);
|
||||||
|
color: #526178;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-empty-preview i {
|
||||||
|
@apply text-4xl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { useClient } from '@/plugins/api.js';
|
||||||
|
|
||||||
|
export const useFeedbackSubmissionStore = defineStore('feedback-submission', () => {
|
||||||
|
const client = useClient();
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
async function submitFeedback(payload, screenshotBlob) {
|
||||||
|
if (isSubmitting.value) {
|
||||||
|
throw new Error('A feedback submission is already in progress.');
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.post('/api/feedback', payload);
|
||||||
|
let report = response.data;
|
||||||
|
|
||||||
|
if (screenshotBlob && report?.id) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', screenshotBlob, 'feedback-screenshot.jpg');
|
||||||
|
|
||||||
|
const screenshotResponse = await client.post(`/api/my-feedback/${report.id}/screenshot`, formData);
|
||||||
|
report = screenshotResponse.data ?? report;
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
} catch (submitError) {
|
||||||
|
console.error('Failed to submit feedback:', submitError);
|
||||||
|
error.value = 'Failed to submit feedback.';
|
||||||
|
throw submitError;
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSubmitting,
|
||||||
|
error,
|
||||||
|
submitFeedback,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -99,6 +99,41 @@
|
|||||||
"assetRevisionCreated": "Asset revision created"
|
"assetRevisionCreated": "Asset revision created"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"feedback": {
|
||||||
|
"button": "Feedback",
|
||||||
|
"open": "Send product feedback",
|
||||||
|
"eyebrow": "Product feedback",
|
||||||
|
"title": "Send feedback",
|
||||||
|
"capture": "Capture screen",
|
||||||
|
"removeCapture": "Remove capture",
|
||||||
|
"noCapture": "Capture the current app viewport if a screenshot would help.",
|
||||||
|
"captureFailed": "The screenshot could not be captured. You can still submit feedback without it.",
|
||||||
|
"submit": "Submit feedback",
|
||||||
|
"submitted": "Feedback submitted.",
|
||||||
|
"submitFailed": "Feedback could not be submitted.",
|
||||||
|
"discardConfirm": "Discard this unsent feedback?",
|
||||||
|
"textPrompt": "Text label",
|
||||||
|
"types": {
|
||||||
|
"bug": "Bug",
|
||||||
|
"suggestion": "Suggestion",
|
||||||
|
"request": "Request"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "Type",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Describe what happened, what you expected, or what would improve the workflow."
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"crop": "Crop",
|
||||||
|
"arrow": "Arrow",
|
||||||
|
"ellipse": "Circle",
|
||||||
|
"line": "Line",
|
||||||
|
"freehand": "Freehand",
|
||||||
|
"text": "Text label",
|
||||||
|
"undo": "Undo",
|
||||||
|
"clear": "Clear and reset"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"allClients": "All clients",
|
"allClients": "All clients",
|
||||||
"allChannels": "All channels",
|
"allChannels": "All channels",
|
||||||
|
|||||||
@@ -99,6 +99,41 @@
|
|||||||
"assetRevisionCreated": "Révision de ressource créée"
|
"assetRevisionCreated": "Révision de ressource créée"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"feedback": {
|
||||||
|
"button": "Feedback",
|
||||||
|
"open": "Envoyer un feedback produit",
|
||||||
|
"eyebrow": "Feedback produit",
|
||||||
|
"title": "Envoyer un feedback",
|
||||||
|
"capture": "Capturer l'écran",
|
||||||
|
"removeCapture": "Retirer la capture",
|
||||||
|
"noCapture": "Capturez la vue actuelle de l'application si une image peut aider.",
|
||||||
|
"captureFailed": "La capture d'écran n'a pas pu être faite. Vous pouvez quand même envoyer un feedback sans image.",
|
||||||
|
"submit": "Envoyer le feedback",
|
||||||
|
"submitted": "Feedback envoyé.",
|
||||||
|
"submitFailed": "Le feedback n'a pas pu être envoyé.",
|
||||||
|
"discardConfirm": "Supprimer ce feedback non envoyé ?",
|
||||||
|
"textPrompt": "Libellé texte",
|
||||||
|
"types": {
|
||||||
|
"bug": "Bug",
|
||||||
|
"suggestion": "Suggestion",
|
||||||
|
"request": "Demande"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "Type",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Décrivez ce qui s'est produit, ce que vous attendiez ou ce qui améliorerait le workflow."
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"crop": "Recadrer",
|
||||||
|
"arrow": "Flèche",
|
||||||
|
"ellipse": "Cercle",
|
||||||
|
"line": "Ligne",
|
||||||
|
"freehand": "Main levée",
|
||||||
|
"text": "Texte",
|
||||||
|
"undo": "Annuler",
|
||||||
|
"clear": "Effacer et réinitialiser"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"allClients": "Tous les clients",
|
"allClients": "Tous les clients",
|
||||||
"allChannels": "Tous les canaux",
|
"allChannels": "Tous les canaux",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
VIcon,
|
VIcon,
|
||||||
VProgressCircular,
|
VProgressCircular,
|
||||||
VProgressLinear,
|
VProgressLinear,
|
||||||
|
VSelect,
|
||||||
VSnackbar,
|
VSnackbar,
|
||||||
VTextarea,
|
VTextarea,
|
||||||
VTextField,
|
VTextField,
|
||||||
@@ -42,6 +43,7 @@ const vuetify = createVuetify({
|
|||||||
VProgressLinear,
|
VProgressLinear,
|
||||||
VProgressCircular,
|
VProgressCircular,
|
||||||
VIcon,
|
VIcon,
|
||||||
|
VSelect,
|
||||||
VTextField,
|
VTextField,
|
||||||
VSnackbar,
|
VSnackbar,
|
||||||
VForm,
|
VForm,
|
||||||
|
|||||||
Reference in New Issue
Block a user