- 整 apps/write/ 进 git(含 frontend 源码 + Makefile + systemd unit + k8s service/ingress)
- .gitea/workflows/deploy-write.yml: act_runner fam 用户跑 host shell
cargo build → npm build → install 到 ~/.local/bin/share/config →
systemctl --user daemon-reload + restart → kubectl apply svc/ingress
- 前端 3 处"麻薯"字样去掉(思考中 / placeholder × 2)
注意 ~/.config/write/env 已有 passphrase,CI placeholder 逻辑会跳过不覆盖。
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { api, getToken, setToken, type DocMeta } from './lib/api'
|
||||
import { AsrSession } from './lib/asr'
|
||||
|
||||
const docs = ref<DocMeta[]>([])
|
||||
const activeId = ref<number | null>(null)
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const inputText = ref('')
|
||||
const status = ref('')
|
||||
const statusErr = ref(false)
|
||||
const busy = ref(false)
|
||||
const recording = ref(false)
|
||||
|
||||
const needToken = ref(!getToken())
|
||||
const tokenInput = ref('')
|
||||
|
||||
// mobile UI state
|
||||
const drawerOpen = ref(false)
|
||||
const mobilePane = ref<'editor' | 'preview'>('preview')
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let asr: AsrSession | null = null
|
||||
let voiceBase = '' // committed transcript so partials don't clobber it
|
||||
|
||||
function setStatus(msg: string, err = false): void {
|
||||
status.value = msg
|
||||
statusErr.value = err
|
||||
if (msg && !err) {
|
||||
setTimeout(() => {
|
||||
if (status.value === msg) status.value = ''
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocs(): Promise<void> {
|
||||
try {
|
||||
docs.value = await api.listDocs()
|
||||
if (docs.value.length && activeId.value === null) {
|
||||
await selectDoc(docs.value[0].id)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (String(e).includes('401')) {
|
||||
needToken.value = true
|
||||
} else {
|
||||
setStatus(String(e), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDoc(id: number): Promise<void> {
|
||||
try {
|
||||
const d = await api.getDoc(id)
|
||||
activeId.value = d.id
|
||||
title.value = d.title
|
||||
content.value = d.content
|
||||
drawerOpen.value = false // close drawer on mobile after pick
|
||||
} catch (e: any) {
|
||||
setStatus(String(e), true)
|
||||
}
|
||||
}
|
||||
|
||||
async function newDoc(): Promise<void> {
|
||||
try {
|
||||
const d = await api.createDoc('未命名文档')
|
||||
await loadDocs()
|
||||
await selectDoc(d.id)
|
||||
} catch (e: any) {
|
||||
setStatus(String(e), true)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDoc(id: number): Promise<void> {
|
||||
if (!confirm('删除这个文档?')) return
|
||||
await api.deleteDoc(id)
|
||||
if (activeId.value === id) activeId.value = null
|
||||
await loadDocs()
|
||||
}
|
||||
|
||||
function scheduleSave(): void {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(saveNow, 800)
|
||||
}
|
||||
|
||||
async function saveNow(): Promise<void> {
|
||||
if (activeId.value === null) return
|
||||
try {
|
||||
await api.saveContent(activeId.value, content.value, title.value)
|
||||
// refresh doc list ordering
|
||||
docs.value = await api.listDocs()
|
||||
} catch (e: any) {
|
||||
setStatus(String(e), true)
|
||||
}
|
||||
}
|
||||
|
||||
watch(content, scheduleSave)
|
||||
watch(title, scheduleSave)
|
||||
|
||||
async function sendMessage(): Promise<void> {
|
||||
if (!inputText.value.trim() || busy.value || activeId.value === null) return
|
||||
const text = inputText.value.trim()
|
||||
busy.value = true
|
||||
setStatus('思考中…')
|
||||
inputText.value = ''
|
||||
try {
|
||||
// Ensure latest edits are saved before claude touches the file
|
||||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
|
||||
await api.saveContent(activeId.value, content.value, title.value)
|
||||
const r = await api.chat(activeId.value, text)
|
||||
content.value = r.content
|
||||
const cost = r.cost_usd?.toFixed(4) ?? '?'
|
||||
const turns = r.turns ?? '?'
|
||||
setStatus(`${r.reply || '完成'} • cost=$${cost} turns=${turns}`)
|
||||
} catch (e: any) {
|
||||
setStatus(String(e), true)
|
||||
} finally {
|
||||
busy.value = false
|
||||
docs.value = await api.listDocs()
|
||||
}
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMic(): Promise<void> {
|
||||
if (recording.value) {
|
||||
// stop
|
||||
recording.value = false
|
||||
await asr?.stop()
|
||||
asr = null
|
||||
} else {
|
||||
voiceBase = inputText.value
|
||||
if (voiceBase && !voiceBase.endsWith(' ')) voiceBase += ' '
|
||||
asr = new AsrSession((ev) => {
|
||||
if (ev.kind === 'error') {
|
||||
setStatus(`ASR: ${ev.message}`, true)
|
||||
return
|
||||
}
|
||||
// The Qwen3-ASR server returns cumulative text per partial.
|
||||
inputText.value = voiceBase + ev.text
|
||||
if (ev.kind === 'final') {
|
||||
voiceBase = inputText.value
|
||||
if (voiceBase && !voiceBase.endsWith(' ')) voiceBase += ' '
|
||||
}
|
||||
})
|
||||
try {
|
||||
await asr.start()
|
||||
recording.value = true
|
||||
setStatus('🎙 录音中…再点一次停止')
|
||||
} catch (e: any) {
|
||||
recording.value = false
|
||||
asr = null
|
||||
setStatus(String(e), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveToken(): void {
|
||||
if (!tokenInput.value.trim()) return
|
||||
setToken(tokenInput.value.trim())
|
||||
needToken.value = false
|
||||
tokenInput.value = ''
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
const renderedPreview = computed(() => {
|
||||
return marked.parse(content.value || '*(空文档)*', { breaks: true, gfm: true }) as string
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (!needToken.value) loadDocs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="needToken" class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<h2>请输入 passphrase</h2>
|
||||
<input
|
||||
v-model="tokenInput"
|
||||
type="password"
|
||||
placeholder="WRITE_PASSPHRASE"
|
||||
@keydown.enter="saveToken"
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="btn primary" @click="saveToken">进入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="app"
|
||||
:class="{ 'drawer-open': drawerOpen, 'pane-editor': mobilePane === 'editor', 'pane-preview': mobilePane === 'preview' }"
|
||||
v-show="!needToken"
|
||||
>
|
||||
<div class="mobile-bar">
|
||||
<button class="hamburger" @click="drawerOpen = true" aria-label="open sidebar">☰</button>
|
||||
<div class="doc-title">
|
||||
<input
|
||||
v-if="activeId !== null"
|
||||
v-model="title"
|
||||
placeholder="文档标题"
|
||||
style="border:none;outline:none;background:transparent;font:inherit;font-weight:600;width:100%;"
|
||||
/>
|
||||
<span v-else style="color:#888;">没选中文档</span>
|
||||
</div>
|
||||
<div class="pane-tabs">
|
||||
<button :class="{ active: mobilePane === 'editor' }" @click="mobilePane = 'editor'" title="源码">✏</button>
|
||||
<button :class="{ active: mobilePane === 'preview' }" @click="mobilePane = 'preview'" title="预览">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-backdrop" @click="drawerOpen = false"></div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1>✍ write</h1>
|
||||
<button class="new-btn" @click="newDoc">+ 新建</button>
|
||||
</div>
|
||||
<div class="doc-list">
|
||||
<div
|
||||
v-for="d in docs"
|
||||
:key="d.id"
|
||||
class="doc-item"
|
||||
:class="{ active: d.id === activeId }"
|
||||
@click="selectDoc(d.id)"
|
||||
>
|
||||
<div class="title">{{ d.title || '未命名' }}</div>
|
||||
<button class="del" @click.stop="deleteDoc(d.id)">删</button>
|
||||
</div>
|
||||
<div v-if="!docs.length" style="padding: 12px; color: #888; font-size: 12px;">
|
||||
还没有文档,点 + 新建一个。
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="workspace">
|
||||
<div class="editor-pane">
|
||||
<div class="title-row">
|
||||
<input
|
||||
v-model="title"
|
||||
placeholder="文档标题"
|
||||
:disabled="activeId === null"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor">
|
||||
<textarea
|
||||
v-model="content"
|
||||
placeholder="markdown 源码(此处可手动编辑,也可以让 AI 改)"
|
||||
:disabled="activeId === null"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="preview" v-html="renderedPreview"></div>
|
||||
</div>
|
||||
|
||||
<div class="input-bar">
|
||||
<div class="input-row">
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
placeholder="对话框(Enter 发送 / Shift+Enter 换行)"
|
||||
:disabled="busy || activeId === null"
|
||||
@keydown="onInputKeydown"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn mic"
|
||||
:class="{ on: recording }"
|
||||
:disabled="busy || activeId === null"
|
||||
@click="toggleMic"
|
||||
:title="recording ? '停止录音' : '开始语音输入'"
|
||||
>{{ recording ? '◼' : '🎙' }}</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
:disabled="busy || !inputText.trim() || activeId === null"
|
||||
@click="sendMessage"
|
||||
>发送</button>
|
||||
</div>
|
||||
<div class="status" :class="{ err: statusErr }">{{ status }}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
// Tiny API client. Token is read once from localStorage and attached to every
|
||||
// request. WS endpoints use ?token= query (browsers can't set ws headers).
|
||||
|
||||
const TOKEN_KEY = 'write.token'
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function setToken(t: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, t)
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const t = getToken()
|
||||
return t ? { Authorization: `token ${t}` } : {}
|
||||
}
|
||||
|
||||
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { ...authHeaders() }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
const r = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '')
|
||||
throw new Error(`${method} ${path} → ${r.status}: ${text}`)
|
||||
}
|
||||
if (r.status === 204) return undefined as T
|
||||
return r.json() as Promise<T>
|
||||
}
|
||||
|
||||
export interface DocMeta {
|
||||
id: number
|
||||
title: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DocFull extends DocMeta {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
content: string
|
||||
reply: string
|
||||
cost_usd?: number
|
||||
turns?: number
|
||||
duration_ms?: number
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listDocs: () => req<DocMeta[]>('GET', '/api/docs'),
|
||||
createDoc: (title?: string) => req<DocMeta>('POST', '/api/docs', { title }),
|
||||
getDoc: (id: number) => req<DocFull>('GET', `/api/docs/${id}`),
|
||||
deleteDoc: (id: number) => req<void>('DELETE', `/api/docs/${id}`),
|
||||
saveContent: (id: number, content: string, title?: string) =>
|
||||
req<DocMeta>('PUT', `/api/docs/${id}/content`, { content, title }),
|
||||
chat: (id: number, text: string) =>
|
||||
req<ChatResponse>('POST', `/api/docs/${id}/chat`, { text }),
|
||||
}
|
||||
|
||||
export function asrWsUrl(): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const t = getToken()
|
||||
return `${proto}//${location.host}/asr?token=${encodeURIComponent(t)}`
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Browser-side ASR client. Connects to /asr (proxied to mochi-asr on i7:9000),
|
||||
// captures the mic, downsamples in an AudioWorklet, emits transcript events.
|
||||
|
||||
import { asrWsUrl } from './api'
|
||||
|
||||
export type AsrEvent =
|
||||
| { kind: 'partial'; text: string }
|
||||
| { kind: 'final'; text: string }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export type AsrEventHandler = (e: AsrEvent) => void
|
||||
|
||||
export class AsrSession {
|
||||
private ws: WebSocket | null = null
|
||||
private audioCtx: AudioContext | null = null
|
||||
private node: AudioWorkletNode | null = null
|
||||
private mediaStream: MediaStream | null = null
|
||||
private source: MediaStreamAudioSourceNode | null = null
|
||||
private handler: AsrEventHandler
|
||||
private active = false
|
||||
|
||||
constructor(handler: AsrEventHandler) {
|
||||
this.handler = handler
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.active) return
|
||||
this.active = true
|
||||
|
||||
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
})
|
||||
|
||||
this.audioCtx = new AudioContext()
|
||||
await this.audioCtx.audioWorklet.addModule('/asr-worklet.js')
|
||||
this.node = new AudioWorkletNode(this.audioCtx, 'asr-worklet', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 0,
|
||||
channelCount: 1,
|
||||
})
|
||||
this.source = this.audioCtx.createMediaStreamSource(this.mediaStream)
|
||||
this.source.connect(this.node)
|
||||
|
||||
this.ws = new WebSocket(asrWsUrl())
|
||||
this.ws.binaryType = 'arraybuffer'
|
||||
|
||||
const flushQueue: ArrayBuffer[] = []
|
||||
let wsReady = false
|
||||
|
||||
this.node.port.onmessage = (ev: MessageEvent<ArrayBuffer>) => {
|
||||
if (!this.ws) return
|
||||
if (wsReady && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(ev.data)
|
||||
} else {
|
||||
flushQueue.push(ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.addEventListener('open', () => {
|
||||
wsReady = true
|
||||
while (flushQueue.length) {
|
||||
const buf = flushQueue.shift()!
|
||||
this.ws?.send(buf)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('message', (ev) => {
|
||||
if (typeof ev.data !== 'string') return
|
||||
let data: { text?: string; final?: boolean; error?: string }
|
||||
try {
|
||||
data = JSON.parse(ev.data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (data.error) {
|
||||
this.handler({ kind: 'error', message: data.error })
|
||||
return
|
||||
}
|
||||
if (typeof data.text === 'string') {
|
||||
this.handler(
|
||||
data.final
|
||||
? { kind: 'final', text: data.text }
|
||||
: { kind: 'partial', text: data.text },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
this.handler({ kind: 'error', message: 'asr ws error' })
|
||||
})
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.active) return
|
||||
this.active = false
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'flush' }))
|
||||
}
|
||||
|
||||
if (this.source) this.source.disconnect()
|
||||
if (this.node) this.node.disconnect()
|
||||
if (this.mediaStream) {
|
||||
for (const track of this.mediaStream.getTracks()) track.stop()
|
||||
}
|
||||
if (this.audioCtx) await this.audioCtx.close()
|
||||
|
||||
this.source = null
|
||||
this.node = null
|
||||
this.mediaStream = null
|
||||
this.audioCtx = null
|
||||
|
||||
// Give upstream a moment to send the {"final": true} response.
|
||||
const ws = this.ws
|
||||
this.ws = null
|
||||
setTimeout(() => ws?.close(), 1500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,327 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; margin: 0; width: 100%; overflow-x: hidden; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: #fafafa;
|
||||
color: #222;
|
||||
font-size: 14px;
|
||||
}
|
||||
.app { max-width: 100vw; overflow-x: hidden; }
|
||||
|
||||
button { font: inherit; cursor: pointer; }
|
||||
textarea, input { font: inherit; }
|
||||
|
||||
/* ====== layout ====== */
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid #e5e5e5;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sidebar-header h1 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.sidebar-header .new-btn {
|
||||
margin-left: auto;
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sidebar-header .new-btn:hover { background: #f5f5f5; }
|
||||
|
||||
.doc-list { flex: 1; overflow-y: auto; padding: 6px; }
|
||||
.doc-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.doc-item:hover { background: #f3f3f3; }
|
||||
.doc-item.active { background: #e8efff; }
|
||||
.doc-item .title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-item .meta { font-size: 11px; color: #888; }
|
||||
.doc-item .del {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #c44;
|
||||
font-size: 12px;
|
||||
visibility: hidden;
|
||||
}
|
||||
.doc-item:hover .del { visibility: visible; }
|
||||
|
||||
/* ====== workspace ====== */
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #fafafa;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-pane > * { min-width: 0; min-height: 0; }
|
||||
.workspace > * { min-width: 0; }
|
||||
.input-bar { min-width: 0; box-sizing: border-box; overflow: hidden; }
|
||||
|
||||
.editor-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 0;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
grid-column: 1 / -1;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fff;
|
||||
}
|
||||
.title-row input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.editor, .preview {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
.editor {
|
||||
border-right: 1px solid #e5e5e5;
|
||||
background: #fff;
|
||||
}
|
||||
.editor { overflow-x: hidden; }
|
||||
.editor textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
word-break: break-all;
|
||||
}
|
||||
.preview {
|
||||
background: #fff;
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.preview h1, .preview h2, .preview h3 { margin: 18px 0 8px; }
|
||||
.preview p { margin: 8px 0; }
|
||||
.preview pre {
|
||||
background: #f5f5f5; padding: 10px; border-radius: 6px;
|
||||
font-size: 12px; overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.preview code { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
|
||||
.preview pre code { background: transparent; padding: 0; word-wrap: normal; overflow-wrap: normal; }
|
||||
.preview blockquote { border-left: 3px solid #ddd; margin: 8px 0; padding: 4px 12px; color: #555; }
|
||||
.preview img { max-width: 100%; height: auto; }
|
||||
.preview table { display: block; max-width: 100%; overflow-x: auto; }
|
||||
|
||||
/* ====== input bar ====== */
|
||||
|
||||
.input-bar {
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.input-row { display: flex; gap: 8px; align-items: flex-end; min-width: 0; }
|
||||
.input-row textarea {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
max-height: 160px;
|
||||
resize: none;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.input-row textarea:focus { border-color: #6b7cff; }
|
||||
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn:hover { background: #f5f5f5; }
|
||||
.btn.primary { background: #5566ee; border-color: #4455dd; color: #fff; }
|
||||
.btn.primary:hover { background: #4455dd; }
|
||||
.btn.primary:disabled { background: #aaa; border-color: #aaa; cursor: not-allowed; }
|
||||
.btn.mic { width: 44px; padding: 0; }
|
||||
.btn.mic.on { background: #ee5566; border-color: #dd4455; color: #fff; }
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
padding: 4px 4px 0;
|
||||
min-height: 18px;
|
||||
}
|
||||
.status.err { color: #c44; }
|
||||
|
||||
/* ====== mobile-only chrome ====== */
|
||||
|
||||
.mobile-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding-top: max(8px, env(safe-area-inset-top));
|
||||
}
|
||||
.mobile-bar .hamburger,
|
||||
.mobile-bar .pane-tabs button {
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.mobile-bar .pane-tabs {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.mobile-bar .pane-tabs button.active {
|
||||
background: #5566ee;
|
||||
color: #fff;
|
||||
border-color: #4455dd;
|
||||
}
|
||||
.mobile-bar .doc-title {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mobile-bar .doc-title input { min-width: 0; }
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* ====== mobile layout ====== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.mobile-bar { display: flex; }
|
||||
|
||||
/* sidebar becomes a slide-in drawer */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0; bottom: 0;
|
||||
width: 78vw;
|
||||
max-width: 320px;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
border-bottom: none;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
z-index: 60;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.app.drawer-open .sidebar { transform: translateX(0); }
|
||||
.app.drawer-open .sidebar-backdrop { display: block; }
|
||||
|
||||
/* workspace fills the screen; editor + preview overlay each other,
|
||||
pane-tabs picks which one shows */
|
||||
.editor-pane {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
position: relative;
|
||||
}
|
||||
.title-row { display: none; } /* title shows in the mobile-bar instead */
|
||||
|
||||
.editor, .preview {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
padding: 12px;
|
||||
}
|
||||
.editor { display: none; }
|
||||
.preview { display: none; }
|
||||
.app.pane-editor .editor { display: block; }
|
||||
.app.pane-preview .preview { display: block; }
|
||||
|
||||
/* input bar honors safe area at bottom */
|
||||
.input-bar {
|
||||
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.input-row textarea {
|
||||
font-size: 16px; /* iOS won't zoom in if >= 16px */
|
||||
}
|
||||
.btn { height: 44px; } /* easier thumb target */
|
||||
.btn.mic { width: 48px; }
|
||||
}
|
||||
|
||||
/* ====== modal ====== */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: #fff; padding: 20px; border-radius: 10px;
|
||||
width: 360px; max-width: 90vw;
|
||||
}
|
||||
.modal h2 { margin: 0 0 12px; font-size: 16px; }
|
||||
.modal input {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px;
|
||||
margin-bottom: 12px; outline: none;
|
||||
}
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
Reference in New Issue
Block a user