auto-cms-server
Version:
Auto turn any webpage into editable CMS without coding.
423 lines (415 loc) • 14 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>auto-cms</title>
<style>
.field {
margin: 1rem;
}
body[data-enabled='true'] [data-show='enabled'] {
display: unset;
}
body[data-enabled='false'] [data-show='not-enabled'] {
display: unset;
}
summary {
padding: 0.25rem;
position: sticky;
top: 0;
background-color: white;
z-index: 1;
}
summary:hover {
background-color: #ff000022;
}
.dir--meta {
display: inline-block;
padding-top: 0.5rem;
padding-inline-start: 0.5rem;
}
summary > .dir--meta {
padding-top: 0;
padding-inline-start: 0;
}
.dir--buttons {
margin: 0.25rem 1rem;
}
[data-template='dir'] {
position: relative;
outline: 1px solid red;
}
[data-template='dir'] .dir {
margin-inline-start: 1rem;
}
[data-template='dir'] .dir::before {
background-color: #ff000022;
position: absolute;
content: '';
width: 1rem;
left: 0;
top: 0;
bottom: 0;
}
figure {
outline: 1px solid red;
margin: 1rem;
}
figcaption {
padding: 0.5rem;
}
img, video, audio {
outline: 1px solid red;
max-width: 100%;
max-height: 100dvh;
background-image: url('/auto-cms/transparent-grid.svg');
}
.media-file--caption--upper {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
code.url {
background-color: #aaaaaa20;
border-radius: 0.25rem;
padding: 0.25rem;
position: relative;
display: block;
width: fit-content;
}
.toast-message {
position: absolute;
bottom: 0;
left: 0;
transform: translateY(100%);
z-index: 1;
background-color: #005500;
color: white;
padding: 0.5rem;
border-radius: 0.5rem;
width: max-content;
font-weight: bold;
}
dialog {
z-index: 2;
position: sticky;
top: 1rem;
background-color: white;
padding: 0.5rem;
}
dialog img {
max-height: calc(100dvh - 20rem);
}
</style>
</head>
<body>
<h1>auto-cms</h1>
<a href="/" target="_blank">Home Page</a>
<form action="/auto-cms/login" method="post" data-show="not-enabled" hidden>
<div class="field">
<label>
password:
<input type="password" name="password" />
</label>
</div>
<div class="field">
<button>login</button>
</div>
</form>
<form action="/auto-cms/logout" method="post" data-show="enabled" hidden>
<div class="field">
<button>logout</button>
</div>
</form>
<div data-show="enabled" hidden>
<h2>Media Files</h2>
<dialog id="uploadDialog">
<button onclick="uploadDialog.close()">close</button>
<h2 class="uploadDialog--title"></h2>
<input type="file" class="uploadDialog--file" />
<figure>
<img />
<video controls></video>
<audio controls></audio>
<figcaption>
<div>Filename: <span class="uploadDialog--filename"></span></div>
<div>File size: <span class="uploadDialog--file-size"></span></div>
<div>
Pathname:
<code class="url" data-text="url" onclick="copyUrl(this)"></code>
</div>
</figcaption>
</figure>
<div>
<button class="uploadDialog--upload">Confirm Upload</button>
</div>
</dialog>
<div id="rootDir" data-template="dir" data-bind="dir"></div>
<template data-name="dir">
<div class="dir" data-id="id">
<div class="dir--meta">
<div>dir: <span data-text="name"></span></div>
<div class="dir--buttons">
<button data-onclick="upload">upload</button>
</div>
<div>
total media files: <span data-text="total_media_count"></span>
</div>
</div>
<details data-if="has_dirs">
<summary>
<div class="dir--meta">
<div>sub-dirs: <span data-text="sub_dir_count"></span></div>
</div>
</summary>
<div class="sub-dirs" data-template="dir" data-bind="dirs"></div>
</details>
<details data-if="has_files">
<summary>
<div class="dir--meta">
<div>sub-files: <span data-text="sub_file_count"></span></div>
</div>
</summary>
<div
class="sub-files"
data-template="media-file"
data-bind="files"
></div>
</details>
</div>
</template>
<template data-name="media-file">
<figure class="media-file">
<img data-if="is_img" data-src="url" loading="lazy" />
<video
controls
data-if="is_video"
data-src="url"
loading="lazy"
></video>
<audio
controls
data-if="is_audio"
data-src="url"
loading="lazy"
></audio>
<figcaption>
<div class="media-file--caption--upper">
<div>
<div class="media-file--filename" data-text="filename"></div>
<div class="media-file--size" data-text="size"></div>
</div>
<div>
<button data-onclick="replace">replace</button>
<button data-onclick="delete">delete</button>
</div>
</div>
<code class="url" data-text="url" onclick="copyUrl(this)"></code>
</figcaption>
</figure>
</template>
</div>
<script src="https://cdn.jsdelivr.net/npm/data-template@1.10/base.js"></script>
<script>
function copyUrl(node) {
let selection = window.getSelection()
let range = document.createRange()
range.selectNodeContents(node)
selection.removeAllRanges()
selection.addRange(range)
document.execCommand('copy')
let span = document.createElement('span')
span.className = 'toast-message'
span.textContent = 'copied into clipboard'
node.appendChild(span)
setTimeout(() => {
span.remove()
}, 3000)
}
function uploadMediaFile(dir, media_file, cb) {
uploadDialog.querySelector('.uploadDialog--title').textContent =
media_file
? `Replace "${media_file.filename}"`
: `Upload media file to "${dir.name}"`
let input = uploadDialog.querySelector('.uploadDialog--file')
let img = uploadDialog.querySelector('img')
let video = uploadDialog.querySelector('video')
let audio = uploadDialog.querySelector('audio')
let mediaNodes = [img, video, audio]
let imageFilenameNode = uploadDialog.querySelector(
'.uploadDialog--filename',
)
let imageFileSizeNode = uploadDialog.querySelector(
'.uploadDialog--file-size',
)
let codeUrlNode = uploadDialog.querySelector('code.url')
let uploadButton = uploadDialog.querySelector('.uploadDialog--upload')
for (let node of mediaNodes) {
node.src = ''
node.hidden = true
}
imageFilenameNode.textContent = ''
imageFileSizeNode.textContent = ''
codeUrlNode.textContent = media_file?.url || dir.url + '/?'
uploadButton.textContent = 'Waiting file selection...'
uploadButton.disabled = true
input.onchange = () => {
let file = input.files[0]
if (!file) return
for (let char of ['#', ':', '/']) {
if (file.name.includes(char)) {
alert(`Cannot contains "${char}" in filename`)
return
}
}
uploadButton.textContent = 'loading file...'
uploadButton.disabled = true
let reader = new FileReader()
let enableUpload = () => {
uploadButton.disabled = false
uploadButton.textContent = 'Upload File'
uploadButton.onclick = event => {
let formData = new FormData()
formData.set('file', file)
fetch('/auto-cms/file', {
method: 'PUT',
headers: {
'X-Pathname': encodeURIComponent(codeUrlNode.textContent),
},
body: formData,
})
.then(res => res.json())
.then(json => {
if (json.error) {
alert(json.error)
return
}
uploadButton.onclick = null
uploadButton.textContent = 'uploaded'
cb()
})
}
}
reader.onload = () => {
for (let node of mediaNodes) {
node.onload = null
node.src = ''
node.hidden = true
// for img
node.onload = () => {
node.onload = null
if (!node.src) return
enableUpload()
}
// for video
node.onloadeddata = node.onload
}
let mimeType = reader.result.match(/data:(.*?),?;/)[1]
if (mimeType.startsWith('image/')) {
// TODO auto compress image to webp
img.hidden = false
img.src = reader.result
} else if (mimeType.startsWith('video/')) {
video.hidden = false
video.src = reader.result
} else if (mimeType.startsWith('audio/')) {
audio.hidden = false
audio.src = reader.result
} else {
enableUpload()
}
imageFilenameNode.textContent = file.name
imageFileSizeNode.textContent = file.size.toLocaleString()
codeUrlNode.textContent = file?.url || dir.url + '/' + file.name
}
reader.readAsDataURL(file)
}
input.click()
uploadDialog.show()
}
getJSON('/auto-cms/status', json => {
document.body.dataset.enabled = json.enabled
if (json.enabled) {
getJSON('/auto-cms/media-list', json => {
console.log(json)
// renderTemplate(imageList, json)
let id = 0
function showDir(host, dir) {
dir.id = 'dir-' + ++id
dir.sub_dir_count = dir.dirs.length
dir.sub_file_count = dir.files.length
dir.has_dirs = dir.dirs.length > 0
dir.has_files = dir.files.length > 0
dir.upload = event => {
uploadMediaFile(dir, null, () => {
event.target.textContent = 'uploaded'
})
}
renderTemplate(host, { dir })
let node = document.getElementById(dir.id)
if (!node) throw new Error('dir node not found: ' + dir.id)
let details = node.querySelector('details')
node.onclick = event => {
if (event.target == node) {
details.open = false
details.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
for (let file of dir.files) {
file.is_img = file.mimetype.startsWith('image/')
file.is_video = file.mimetype.startsWith('video/')
file.is_audio = file.mimetype.startsWith('audio/')
file.replace = event => {
if (event.target.tagName.toLowerCase() == 'input') return
uploadMediaFile(dir, file, () => {
event.target.textContent = 'replaced'
})
}
file.delete = event => {
let confirm_text = 'confirm to delete'
let ans = prompt(
`${confirm_text} "${file.filename}"?`,
confirm_text,
)
if (ans != confirm_text) return
fetch('/auto-cms/file', {
method: 'DELETE',
headers: { 'X-Pathname': encodeURIComponent(file.url) },
})
.then(res => res.json())
.then(json => {
if (json.error) {
alert(json.error)
return
}
event.target.textContent = 'deleted'
})
}
}
if (dir.files.length > 0) {
renderTemplate(node.querySelector('[data-bind="files"]'), dir)
}
if (dir.dirs.length > 0) {
renderTemplate(node.querySelector('[data-bind="dirs"]'), dir)
}
let subDirNodes = node.querySelectorAll('.sub-dirs > .dir')
for (let i = 0; i < subDirNodes.length; i++) {
let subDirNode = subDirNodes[i]
let subDir = dir.dirs[i]
subDirNode.classList.remove('dir')
subDirNode.dataset.template = 'dir'
subDirNode.dataset.bind = 'dir'
showDir(subDirNode, subDir)
}
}
showDir(rootDir, json.dir)
})
}
})
</script>
</body>
</html>