/** Upload a video, and edit the details of one you already own. */
import { h, icon, mount } from '../dom.js';
import { api, uploadVideo } from '../api.js';
import { state } from '../store.js';
import { navigate } from '../router.js';
import { toast, emptyState, spinner } from '../ui.js';
import { fileSize, timecode } from '../format.js';
const ACCEPT = 'video/mp4,video/webm,video/quicktime,video/x-m4v,video/ogg,.mp4,.webm,.mov,.m4v,.ogg';
const MAX_BYTES = 512 * 1024 * 1024;
export async function uploadView() {
if (!state.user) {
return emptyState('Sign in to upload', 'You need an account before you can publish a video.',
h('a.btn.btn--primary', { href: '/signin?next=%2Fupload' }, 'Sign in'));
}
document.title = 'Upload · MeTube';
let file = null;
const input = h('input', { type: 'file', accept: ACCEPT, hidden: true });
const dropzone = h('div.dropzone', { tabindex: '0', role: 'button', 'aria-label': 'Choose a video file' },
icon('upload', 38),
h('div.dropzone__title', { text: 'Drop a video here' }),
h('div.dropzone__hint', { text: 'or click to browse · MP4, WebM, MOV or OGG · up to 512 MB' }),
);
const fileSlot = h('div', {}, dropzone);
const title = h('input.input', { maxlength: '120', placeholder: 'Give it a clear, specific title', required: true });
const description = h('textarea.textarea', { maxlength: '5000', placeholder: 'What is in this video? Add chapters, links or credits.' });
const category = h('select.select', {},
state.categories.filter((c) => c !== 'All').map((c) => h('option', { value: c, selected: c === 'General' }, c)),
);
const error = h('div.formerror', { hidden: true, role: 'alert' });
const progressFill = h('div.progress__fill');
const progressPct = h('span.num', { text: '0%' });
const progressBytes = h('span', { text: '' });
const progressBox = h('div', { hidden: true },
h('div.progress', {}, progressFill),
h('div.progress__label', {}, h('span', {}, 'Uploading… ', progressPct), progressBytes),
);
const submit = h('button.btn.btn--primary', { type: 'submit', disabled: true }, icon('upload', 18), 'Publish video');
const setFile = (chosen) => {
if (!chosen) return;
if (chosen.size > MAX_BYTES) {
showError(`That file is ${fileSize(chosen.size)}. The limit is 512 MB.`);
return;
}
if (!/^video\//.test(chosen.type) && !/\.(mp4|webm|mov|m4v|ogg)$/i.test(chosen.name)) {
showError('That is not a video file. Choose an MP4, WebM, MOV or OGG.');
return;
}
file = chosen;
error.hidden = true;
submit.disabled = false;
if (!title.value.trim()) title.value = chosen.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').slice(0, 120);
const media = h('video', { src: URL.createObjectURL(chosen), muted: true, preload: 'metadata',
style: { width: '104px', borderRadius: '8px', background: '#000' } });
const meta = h('div.filecard__size', { text: fileSize(chosen.size) });
media.addEventListener('loadedmetadata', () => {
meta.textContent = `${fileSize(chosen.size)} · ${timecode(media.duration)}`;
});
mount(fileSlot, h('div.filecard', {},
media,
h('div', { style: { minWidth: 0, flex: '1 1 auto' } },
h('div.filecard__name', { text: chosen.name }),
meta,
),
h('button.btn.btn--ghost.btn--sm', {
type: 'button',
onclick: () => {
URL.revokeObjectURL(media.src);
file = null;
submit.disabled = true;
input.value = '';
mount(fileSlot, dropzone);
},
}, 'Change'),
));
};
const showError = (message) => {
error.textContent = message;
error.hidden = false;
};
dropzone.addEventListener('click', () => input.click());
dropzone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); input.click(); }
});
input.addEventListener('change', () => setFile(input.files[0]));
for (const type of ['dragenter', 'dragover']) {
dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add('is-over'); });
}
for (const type of ['dragleave', 'drop']) {
dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove('is-over'); });
}
dropzone.addEventListener('drop', (event) => setFile(event.dataTransfer?.files?.[0]));
const form = h('form.panel', {
onsubmit: async (event) => {
event.preventDefault();
if (!file) return showError('Choose a video file first.');
if (!title.value.trim()) return showError('Give your video a title.');
error.hidden = true;
submit.disabled = true;
progressBox.hidden = false;
const data = new FormData();
data.append('title', title.value.trim());
data.append('description', description.value.trim());
data.append('category', category.value);
data.append('video', file, file.name);
try {
const { video } = await uploadVideo(data, (fraction, loaded, total) => {
const pct = Math.round(fraction * 100);
progressFill.style.width = `${pct}%`;
progressPct.textContent = `${pct}%`;
progressBytes.textContent = `${fileSize(loaded)} of ${fileSize(total)}`;
if (pct === 100) progressBytes.textContent = 'Processing on the server…';
});
toast('Published', 'good');
navigate(`/watch/${video.id}`);
} catch (err) {
showError(err.message);
submit.disabled = false;
progressBox.hidden = true;
progressFill.style.width = '0';
}
},
},
error,
h('div.field', {}, h('label.field__label', { text: 'Video file' }), fileSlot, input),
h('div.field', {}, h('label.field__label', { text: 'Title' }), title),
h('div.field', {}, h('label.field__label', { text: 'Description' }), description),
h('div.field', {}, h('label.field__label', { text: 'Category' }), category),
progressBox,
h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '9px', marginTop: '18px' } },
h('a.btn.btn--ghost', { href: '/' }, 'Cancel'),
submit,
),
);
return h('div', { style: { maxWidth: '760px', margin: '0 auto' } },
h('div.pagehead', {},
h('div', {},
h('span.eyebrow', { text: `Publishing as @${state.user.username}` }),
h('h1.pagehead__title', { text: 'Upload a video' }),
),
),
form,
);
}
export async function editView({ id }) {
if (!state.user) {
return emptyState('Sign in to edit', 'You need to be signed in as the owner of this video.',
h('a.btn.btn--primary', { href: `/signin?next=%2Fedit%2F${id}` }, 'Sign in'));
}
const root = h('div', { style: { maxWidth: '760px', margin: '0 auto' } }, spinner());
let video;
try {
({ video } = await api.video(id));
} catch (err) {
return emptyState('That video is not here', err.message, h('a.btn.btn--primary', { href: '/' }, 'Back to home'));
}
if (!video.isOwner) {
return emptyState('You cannot edit this video', 'Only the channel that uploaded a video can change its details.',
h('a.btn.btn--primary', { href: `/watch/${video.id}` }, 'Watch it instead'));
}
mount(root);
document.title = `Edit · ${video.title}`;
const title = h('input.input', { value: video.title, maxlength: '120', required: true });
const description = h('textarea.textarea', { maxlength: '5000' }, video.description);
const category = h('select.select', {},
state.categories.filter((c) => c !== 'All').map((c) => h('option', { value: c, selected: c === video.category }, c)),
);
const error = h('div.formerror', { hidden: true, role: 'alert' });
const save = h('button.btn.btn--primary', { type: 'submit' }, 'Save changes');
const form = h('form.panel', {
onsubmit: async (event) => {
event.preventDefault();
save.disabled = true;
error.hidden = true;
try {
await api.updateVideo(video.id, {
title: title.value.trim(),
description: description.value.trim(),
category: category.value,
});
toast('Details saved', 'good');
navigate(`/watch/${video.id}`);
} catch (err) {
error.textContent = err.message;
error.hidden = false;
save.disabled = false;
}
},
},
error,
h('div.field', {}, h('label.field__label', { text: 'Title' }), title),
h('div.field', {}, h('label.field__label', { text: 'Description' }), description),
h('div.field', {}, h('label.field__label', { text: 'Category' }), category),
h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '9px', marginTop: '18px' } },
h('a.btn.btn--ghost', { href: `/watch/${video.id}` }, 'Cancel'),
save,
),
);
root.append(
h('div.pagehead', {},
h('div', {},
h('span.eyebrow', { text: 'Edit details' }),
h('h1.pagehead__title', { text: video.title }),
),
),
h('div', { style: { display: 'flex', gap: '16px', marginBottom: '20px', alignItems: 'center' } },
video.thumb ? h('img', { src: video.thumb, alt: '', style: { width: '190px', borderRadius: '10px' } }) : null,
h('div', { style: { color: 'var(--haze)', fontSize: '13px' } },
h('div', {}, 'Thumbnail is generated from the video.'),
h('a', { href: `/watch/${video.id}`, style: { color: 'var(--amber)', fontWeight: '600' } }, 'Open watch page'),
),
),
form,
);
return root;
}