UNPKG

auto-cms-server

Version:

Auto turn any webpage into editable CMS without coding.

423 lines (415 loc) 14 kB
<!DOCTYPE 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>