Guides image + draft gating, audit retention, ADR-015 gate, backup credential redaction

This commit is contained in:
2026-08-26 15:08:53 -04:00
parent 4e38303326
commit 0738d71525
37 changed files with 1953 additions and 129 deletions

View File

@@ -142,7 +142,12 @@
</div>
<div v-if="block.type === 'image'" class="ge-image">
<img v-if="block.url" :src="block.url" alt="">
<!-- Same authenticated fetch as the reader's view: an author must
see the screenshot they just pasted. -->
<img v-if="imageState(block).src" :src="imageState(block).src" alt="">
<p v-else-if="imageState(block).failed" class="ge-imagefailed">
Image unavailable - it may have been removed
</p>
<input v-model="block.caption" placeholder="Caption (optional)">
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
</div>
@@ -203,6 +208,7 @@ import { useAuthStore } from '@/stores/auth'
import GuideBlocks from '../components/GuideBlocks.vue'
import { templates } from '../utils/templates.js'
import { proof, applyFix } from '../utils/proof.js'
import { useGuideImages } from '../utils/images.js'
const route = useRoute()
const router = useRouter()
@@ -382,7 +388,10 @@ async function insertImage (file) {
status.value = 'Uploading image...'
try {
const image = await uploadImage(file)
const block = { id: newid(), type: 'image', url: image.url, caption: '', alt: '' }
// imageid is what the renderer resolves; url stays for older readers and
// for anything that still treats a block as a plain document.
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
url: image.url, caption: '', alt: '' }
blocks.value.splice(insertIndex(), 0, block)
focused.value = block.id
status.value = ''
@@ -413,15 +422,21 @@ function onFilePick (event) {
event.target.value = ''
}
// Editor thumbnails resolve the same way the reader's do; see utils/images.js.
const { state: imageState } = useGuideImages(blocks)
function payload () {
return { title: guide.title, summary: guide.summary || '',
tags: guide.tags || '', blocks: blocks.value }
}
// Returns whether the save actually reached the server. publish() depends on
// that answer: a publish that runs after a failed save promotes the LAST
// revision the server accepted, which is not what is on the author's screen.
async function save (quiet) {
if (!guide.title.trim()) {
if (!quiet) error.value = 'A guide needs a title before it can be saved'
return
return false
}
saving.value = true
error.value = ''
@@ -439,9 +454,11 @@ async function save (quiet) {
dirty.value = false
status.value = 'Saved ' + new Date().toLocaleTimeString()
await loadRevisions()
return true
} catch (err) {
error.value = apiError(err, 'Could not save this guide')
status.value = 'Not saved'
return false
} finally {
saving.value = false
}
@@ -476,7 +493,14 @@ async function publish () {
+ 'anything flagged in a command. Publish anyway?')
if (!ok) return
}
await save(true)
// Publishing promotes the newest SAVED revision, so an unsaved edit is not
// in what readers get. Reporting "Published version 4" over a failed save
// tells the author their words went out when the old ones did.
if (!await save(true)) {
error.value = error.value
|| 'Could not save this guide, so it was not published'
return
}
try {
const response = await api.post('/guides/' + guide.guideid + '/publish')
guide.ispublished = true
@@ -637,6 +661,8 @@ onMounted(async () => {
background: transparent; color: var(--text-light); border-radius: 3px;
cursor: pointer; justify-self: start;
}
.ge-imagefailed { margin: 0 0 0.35rem; padding: 0.5rem 0.7rem; font-size: 0.85rem;
color: var(--danger, #b3261e); border: 1px dashed var(--border); border-radius: 4px; }
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
border: 1px solid var(--border); border-radius: 4px; }
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;