Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,26 @@
"exports": {
"./components/*": "./src/components/*",
"./pages/*": "./src/pages/*",
"./locales": "./src/locales/index.js",
"./locales/*": "./src/locales/*",
".": "./src/index.js"
},
"files": [
"src"
],
"peerDependencies": {
"vue": "^3.3.0",
"@inertiajs/vue3": "^1.0.0 || ^2.0.0"
"@inertiajs/vue3": "^1.0.0 || ^2.0.0",
"vue": "^3.3.0"
},
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vue": "^3.5.0",
"@inertiajs/vue3": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vue/test-utils": "^2.4.0",
"happy-dom": "^15.0.0",
"vitest": "^2.0.0"
"vitest": "^2.0.0",
"vue": "^3.5.0"
}
}
9 changes: 6 additions & 3 deletions src/components/PinnedNotes.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script setup>
import { ref, computed, inject } from 'vue';
import { router } from '@inertiajs/vue3';
import { sanitizeHtml } from '../utils/sanitizeHtml';
import { useI18n } from '../composables/useI18n';

const props = defineProps({
notes: { type: Array, default: () => [] },
Expand All @@ -9,6 +11,7 @@ const props = defineProps({
});

const escDark = inject('esc-dark', computed(() => false));
const { t } = useI18n();
const processingId = ref(null);

function unpinNote(note) {
Expand Down Expand Up @@ -49,7 +52,7 @@ function formatDate(dateStr) {
</svg>
<span :class="['text-xs font-semibold uppercase tracking-wider',
escDark ? 'text-amber-400' : 'text-amber-700']">
Pinned Notes
{{ t('pinned_notes.title') }}
</span>
</div>

Expand All @@ -61,7 +64,7 @@ function formatDate(dateStr) {
? 'border-amber-500/10 bg-amber-500/[0.04]'
: 'border-amber-200 bg-white']">
<!-- Note body -->
<div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="note.body"></div>
<div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-neutral-200' : 'text-gray-800']" v-html="sanitizeHtml(note.body)"></div>

<!-- Meta row -->
<div class="mt-2 flex items-center justify-between">
Expand All @@ -78,7 +81,7 @@ function formatDate(dateStr) {
? 'text-amber-400 hover:bg-amber-500/10 hover:text-amber-300'
: 'text-amber-600 hover:bg-amber-100 hover:text-amber-700',
processingId === note.id && 'opacity-50 cursor-not-allowed']">
Unpin
{{ t('pinned_notes.unpin') }}
</button>
</div>
</div>
Expand Down
15 changes: 9 additions & 6 deletions src/components/ReplyThread.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { inject, computed } from 'vue';
import { router } from '@inertiajs/vue3';
import AttachmentList from './AttachmentList.vue';
import { sanitizeHtml } from '../utils/sanitizeHtml';
import { useI18n } from '../composables/useI18n';

const props = defineProps({
replies: { type: Array, required: true },
Expand All @@ -12,6 +14,7 @@ const props = defineProps({
});

const escDark = inject('esc-dark', computed(() => false));
const { t } = useI18n();

function formatDate(date) {
return new Date(date).toLocaleString();
Expand All @@ -31,14 +34,14 @@ function togglePin(reply) {
: (reply.is_internal_note ? 'border-yellow-200 bg-yellow-50' : 'border-gray-200 bg-white')]">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<span :class="['font-medium', escDark ? 'text-gray-200' : 'text-gray-900']">{{ reply.author?.name || 'Unknown' }}</span>
<span :class="['font-medium', escDark ? 'text-gray-200' : 'text-gray-900']">{{ reply.author?.name || t('ticket.unknown') }}</span>
<span v-if="reply.is_internal_note"
:class="['rounded px-1.5 py-0.5 text-xs font-medium', escDark ? 'bg-amber-500/15 text-amber-400' : 'bg-yellow-200 text-yellow-800']">
Internal Note
{{ t('reply.internal_note') }}
</span>
<span v-if="reply.is_pinned"
:class="['rounded px-1.5 py-0.5 text-xs font-medium', escDark ? 'bg-cyan-500/15 text-cyan-400' : 'bg-blue-100 text-blue-700']">
Pinned
{{ t('reply.pinned') }}
</span>
</div>
<div class="flex items-center gap-2">
Expand All @@ -47,14 +50,14 @@ function togglePin(reply) {
escDark
? (reply.is_pinned ? 'bg-cyan-500/15 text-cyan-400 hover:bg-cyan-500/25' : 'text-neutral-500 hover:bg-white/[0.06] hover:text-neutral-300')
: (reply.is_pinned ? 'bg-blue-100 text-blue-700 hover:bg-blue-200' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600')]">
{{ reply.is_pinned ? 'Unpin' : 'Pin' }}
{{ reply.is_pinned ? t('reply.unpin') : t('reply.pin') }}
</button>
<span :class="['text-xs', escDark ? 'text-gray-500' : 'text-gray-500']">{{ formatDate(reply.created_at) }}</span>
</div>
</div>
<div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-gray-300' : 'text-gray-700']" v-html="reply.body"></div>
<div :class="['prose prose-sm max-w-none', escDark ? 'prose-invert text-gray-300' : 'text-gray-700']" v-html="sanitizeHtml(reply.body)"></div>
<AttachmentList v-if="reply.attachments?.length" :attachments="reply.attachments" class="mt-3" />
</div>
<div v-if="!replies?.length" :class="['py-8 text-center text-sm', escDark ? 'text-gray-500' : 'text-gray-500']">No replies yet.</div>
<div v-if="!replies?.length" :class="['py-8 text-center text-sm', escDark ? 'text-gray-500' : 'text-gray-500']">{{ t('ticket.no_replies') }}</div>
</div>
</template>
80 changes: 80 additions & 0 deletions src/composables/useI18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ref, computed } from 'vue';
import locales from '../locales/index.js';

const currentLocale = ref('en');
const customMessages = ref({});

function resolve(obj, path) {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}

function applyReplacements(str, replacements) {
if (!replacements) return str;
return Object.entries(replacements).reduce(
(result, [key, value]) => result.replace(new RegExp(`:${key}`, 'g'), value),
str,
);
}

export function setLocale(locale) {
currentLocale.value = locale;
}

export function getLocale() {
return currentLocale.value;
}

export function mergeMessages(locale, messages) {
customMessages.value = {
...customMessages.value,
[locale]: deepMerge(customMessages.value[locale] || {}, messages),
};
}

function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key]) &&
target[key] &&
typeof target[key] === 'object'
) {
result[key] = deepMerge(target[key], source[key]);
} else {
result[key] = source[key];
}
}
return result;
}

export function useI18n() {
const locale = computed(() => currentLocale.value);

function t(key, replacements) {
const lang = currentLocale.value;

// Try custom messages for current locale
const custom = resolve(customMessages.value[lang], key);
if (typeof custom === 'string') return applyReplacements(custom, replacements);

// Try built-in messages for current locale
const msg = resolve(locales[lang], key);
if (typeof msg === 'string') return applyReplacements(msg, replacements);

// Fallback to English
if (lang !== 'en') {
const customEn = resolve(customMessages.value.en, key);
if (typeof customEn === 'string') return applyReplacements(customEn, replacements);

const fallback = resolve(locales.en, key);
if (typeof fallback === 'string') return applyReplacements(fallback, replacements);
}

// Return raw key
return key;
}

return { t, locale };
}
Loading