- The first message of a thread must carry name + email introducing the visitor; the panel shows the two fields until the thread has an identity (send stays disabled while they look empty, so the draft is never lost), prefilled from and saved to localStorage. The identity is stored on ContactThread (msgspec defaults — old databases decode unchanged) and fixed afterwards; it labels the visitor's messages instead of 'you', and the admin inbox lists threads by name with the email as a mailto in the thread header. - Timestamps use the analytics relative formatter (formatWhen), with the full date on hover. - Message headers alternate alignment (own messages right, theirs left) as the visual side cue; the sent-from page link is dropped from the display (kept as stored metadata). - Composer gains a sendDisabled prop for the intro gate.
253 lines
7.2 KiB
Vue
253 lines
7.2 KiB
Vue
<script setup>
|
|
// The message composer shared by the visitor contact panel and the admin
|
|
// inbox: a constrained contenteditable (plain-text paste, toolbar-driven
|
|
// formatting, upload chips) serialized to the Markdown subset on send
|
|
// (./richtext.js). The server re-renders that with its strict parser —
|
|
// what shows in threads is never this DOM.
|
|
import { onMounted, ref } from 'vue'
|
|
import { apiFetch } from 'paskia'
|
|
import { serializeRich } from './richtext.js'
|
|
|
|
const props = defineProps({
|
|
placeholder: { type: String, default: 'Write a message…' },
|
|
// Visitor session token for the public upload endpoint; empty = admin,
|
|
// whose uploads go to the /_api file store instead.
|
|
token: { type: String, default: '' },
|
|
autofocus: { type: Boolean, default: false },
|
|
// When true, send (button and Ctrl+Enter) is inert and the draft is
|
|
// kept — e.g. the contact panel's first-message name/email fields are
|
|
// still empty.
|
|
sendDisabled: { type: Boolean, default: false },
|
|
})
|
|
const emit = defineEmits(['send'])
|
|
|
|
const box = ref(null)
|
|
const imageInput = ref(null)
|
|
const fileInput = ref(null)
|
|
const uploading = ref(0)
|
|
const error = ref('')
|
|
let errorTimer = null
|
|
|
|
function showError(text) {
|
|
error.value = text
|
|
clearTimeout(errorTimer)
|
|
errorTimer = setTimeout(() => { error.value = '' }, 6000)
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.autofocus) box.value?.focus()
|
|
})
|
|
|
|
function cmd(name, value = null) {
|
|
box.value?.focus()
|
|
document.execCommand(name, false, value)
|
|
}
|
|
|
|
function link() {
|
|
const url = window.prompt('Link URL', 'https://')
|
|
if (url && /^(https?:\/\/|mailto:)/.test(url)) cmd('createLink', url)
|
|
}
|
|
|
|
const escHtml = (s) =>
|
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
|
|
// Paste is plain text only — the grammar (bold, links, chips…) comes from
|
|
// the toolbar and uploads, never from foreign HTML.
|
|
function onPaste(ev) {
|
|
ev.preventDefault()
|
|
if (ev.clipboardData?.files?.length) {
|
|
uploadFiles(ev.clipboardData.files)
|
|
return
|
|
}
|
|
const text = ev.clipboardData?.getData('text/plain') || ''
|
|
if (!text) return
|
|
const html = text
|
|
.split(/\n{2,}/)
|
|
.map((p) => `<div>${p.split(/\r\n|\r|\n/).map(escHtml).join('<br>')}</div>`)
|
|
.join('')
|
|
document.execCommand('insertHTML', false, html)
|
|
}
|
|
|
|
function onDrop(ev) {
|
|
if (ev.dataTransfer?.files?.length) {
|
|
ev.preventDefault()
|
|
uploadFiles(ev.dataTransfer.files)
|
|
}
|
|
}
|
|
|
|
async function uploadFiles(files) {
|
|
for (const file of files) {
|
|
if (file.size > 20 * 1024 * 1024) {
|
|
showError(`"${file.name}" is over 20 MB`)
|
|
continue
|
|
}
|
|
uploading.value++
|
|
try {
|
|
const path = `/_${props.token ? 'contact' : 'api'}/files/${encodeURIComponent(file.name)}`
|
|
const init = { method: 'PUT', body: file }
|
|
if (props.token) init.headers = { 'x-contact-token': props.token }
|
|
const r = await (props.token ? fetch(path, init) : apiFetch(path, init))
|
|
if (!r.ok) {
|
|
const detail = await r.json().then((d) => d?.detail, () => null)
|
|
throw new Error(detail || `status ${r.status}`)
|
|
}
|
|
insertUpload(await r.json())
|
|
} catch (e) {
|
|
console.error('contact upload failed:', e)
|
|
showError(`could not upload "${file.name}": ${e.message}`)
|
|
} finally {
|
|
uploading.value--
|
|
}
|
|
}
|
|
}
|
|
|
|
// An uploaded file becomes an inline image or an attachment chip; both
|
|
// serialize back to /_f/ Markdown references on send.
|
|
function insertUpload({ path, image, name }) {
|
|
const html = image
|
|
? `<img class="contact-img" src="${escHtml(path)}" data-path="${escHtml(path)}" alt="${escHtml(name)}">`
|
|
: `<span class="contact-file" contenteditable="false" data-path="${escHtml(path)}" data-name="${escHtml(name)}">📎 ${escHtml(name)}</span> `
|
|
box.value?.focus()
|
|
document.execCommand('insertHTML', false, html)
|
|
}
|
|
|
|
function send() {
|
|
const md = serializeRich(box.value)
|
|
if (!md || uploading.value || props.sendDisabled) return
|
|
emit('send', md)
|
|
box.value.innerHTML = ''
|
|
}
|
|
|
|
function onKeydown(ev) {
|
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') send()
|
|
}
|
|
|
|
defineExpose({ focus: () => box.value?.focus() })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="composer">
|
|
<div
|
|
ref="box"
|
|
class="composer-box"
|
|
contenteditable="true"
|
|
:data-placeholder="placeholder"
|
|
role="textbox"
|
|
aria-multiline="true"
|
|
@paste="onPaste"
|
|
@drop="onDrop"
|
|
@keydown="onKeydown"
|
|
></div>
|
|
<div class="composer-bar">
|
|
<button type="button" title="bold" @mousedown.prevent @click="cmd('bold')"><b>B</b></button>
|
|
<button type="button" title="italic" @mousedown.prevent @click="cmd('italic')"><i>I</i></button>
|
|
<button type="button" title="link" @mousedown.prevent @click="link">🔗</button>
|
|
<button type="button" title="list" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button>
|
|
<button type="button" title="image" @mousedown.prevent @click="imageInput.click()">🖼</button>
|
|
<button type="button" title="attachment" @mousedown.prevent @click="fileInput.click()">📎</button>
|
|
<span v-if="uploading" class="uploading">uploading…</span>
|
|
<button type="button" class="send" title="send (Ctrl+Enter)" :disabled="sendDisabled" @mousedown.prevent @click="send">➤</button>
|
|
</div>
|
|
<p v-if="error" class="composer-error">{{ error }}</p>
|
|
<input
|
|
ref="imageInput"
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
hidden
|
|
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
|
>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
multiple
|
|
hidden
|
|
@change="uploadFiles($event.target.files); $event.target.value = ''"
|
|
>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.composer {
|
|
border: 1px solid var(--line);
|
|
border-radius: 0.5rem;
|
|
background: var(--bg, Canvas);
|
|
}
|
|
.composer:focus-within {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.composer-box {
|
|
min-height: 3.2rem;
|
|
max-height: 30vh;
|
|
overflow-y: auto;
|
|
padding: 0.5rem 0.7rem;
|
|
outline: none;
|
|
font: inherit;
|
|
overflow-wrap: break-word;
|
|
}
|
|
.composer-box:empty::before {
|
|
content: attr(data-placeholder);
|
|
color: var(--muted);
|
|
pointer-events: none;
|
|
}
|
|
.composer-box :deep(img.contact-img) {
|
|
display: block;
|
|
max-width: 100%;
|
|
max-height: 10rem;
|
|
margin: 0.2rem 0;
|
|
border-radius: 0.3rem;
|
|
}
|
|
.composer-box :deep(.contact-file) {
|
|
display: inline-block;
|
|
padding: 0.05em 0.5em;
|
|
border: 1px solid var(--line);
|
|
border-radius: 1em;
|
|
background: color-mix(var(--muted) 10%, transparent);
|
|
font-size: 0.9em;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.composer-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.15rem;
|
|
padding: 0.2rem 0.3rem;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
.composer-bar button {
|
|
min-width: 1.7rem;
|
|
padding: 0.15rem 0.3rem;
|
|
border: none;
|
|
border-radius: 0.3rem;
|
|
background: none;
|
|
color: var(--text, CanvasText);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
.composer-bar button:hover {
|
|
background: color-mix(var(--muted) 15%, transparent);
|
|
}
|
|
.composer-bar button:disabled {
|
|
opacity: 0.4;
|
|
cursor: default;
|
|
background: none;
|
|
}
|
|
.composer-bar .uploading {
|
|
margin-left: 0.4rem;
|
|
color: var(--muted);
|
|
font-size: 0.85em;
|
|
}
|
|
.composer-bar .send {
|
|
margin-left: auto;
|
|
color: var(--accent);
|
|
}
|
|
|
|
.composer-error {
|
|
margin: 0;
|
|
padding: 0.2rem 0.7rem 0.35rem;
|
|
color: #b3261e;
|
|
font-size: 0.85em;
|
|
}
|
|
</style>
|