From 6692cce611364dd030828c4c1b3fa71afd74e03f Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Sat, 23 Dec 2023 23:18:53 -0800 Subject: [PATCH 01/15] Update Toast Container --- src/html/popup.html | 6 +++--- src/js/popup.js | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/html/popup.html b/src/html/popup.html index be817bb..777e2e0 100644 --- a/src/html/popup.html +++ b/src/html/popup.html @@ -70,14 +70,14 @@

Django Files

-
+
- diff --git a/src/js/popup.js b/src/js/popup.js index 10c01dd..e083779 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -419,11 +419,12 @@ function showToast(message, type = 'success') { element.querySelector('.toast-body').innerHTML = message document.getElementById('toast-container').appendChild(element) const toast = new bootstrap.Toast(element) + element.addEventListener('mouseover', () => toast.hide()) toast.show() - const callback = () => { - element.addEventListener('mouseover', () => toast.hide()) - } - setTimeout(callback, 1000) + // const callback = () => { + // element.addEventListener('mouseover', () => toast.hide()) + // } + // setTimeout(callback, 1000) } /** From 667ed50ee2f2f5af8e3c96fcb1f561a1e3533079 Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Sun, 24 Dec 2023 01:56:19 -0800 Subject: [PATCH 02/15] Add Context Menu --- src/html/popup.html | 16 +++- src/js/popup.js | 193 +++++++++++++++++++++----------------------- 2 files changed, 103 insertions(+), 106 deletions(-) diff --git a/src/html/popup.html b/src/html/popup.html index 777e2e0..353a3b1 100644 --- a/src/html/popup.html +++ b/src/html/popup.html @@ -57,13 +57,12 @@

Django Files

- + -
Recent Uploads
CopyFile URLDelete
MenuFile URL
@@ -74,10 +73,19 @@

Django Files

+
diff --git a/src/js/popup.js b/src/js/popup.js index e083779..c95b1d7 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -39,11 +39,11 @@ let timeout * @function initPopup */ async function initPopup() { - console.log('initPopup') + console.debug('initPopup') // Get options const { options } = await chrome.storage.sync.get(['options']) - console.log('options:', options) + console.debug('options:', options) // Set Options (this is currently the only one in the popup) document.getElementById('popupPreview').checked = options.popupPreview @@ -101,7 +101,7 @@ async function initPopup() { auth: true, }) } - console.log(`response.status: ${response.status}`, response, data) + console.debug(`response.status: ${response.status}`, response, data) // Check response data is valid and has files if (!response?.ok) { @@ -115,19 +115,21 @@ async function initPopup() { // Update table should only be called here, changes should use initPopup() updateTable(data) - - // Re-init clipboardJS and popupLinks after updateTable - new ClipboardJS('.clip') document - .querySelectorAll('a[href]') - .forEach((el) => el.addEventListener('click', popupLinks)) + .querySelectorAll('.dropdown-item') + .forEach((el) => el.addEventListener('click', ctxMenu)) // Enable Popup Mouseover Preview if popupPreview timeout = options.popupTimeout * 1000 if (options.popupPreview) { - console.log('Enabling Mouseover Preview') initPopupMouseover() } + + // Re-init clipboardJS and popupLinks after updateTable + new ClipboardJS('.clip') + document + .querySelectorAll('a[href]') + .forEach((el) => el.addEventListener('click', popupLinks)) } /** @@ -137,7 +139,7 @@ async function initPopup() { * @param {MouseEvent} event */ async function popupLinks(event) { - console.log('popupLinks:', event) + console.debug('popupLinks:', event) event.preventDefault() const anchor = event.target.closest('a') console.log(`anchor.href: ${anchor.href}`) @@ -157,8 +159,8 @@ async function popupLinks(event) { async function onMessage(message) { // console.log('onMessage: message, sender:', message, sender) if (message?.siteUrl && message?.authToken) { - console.log(`url: ${message.siteUrl}`) - console.log(`token: ${message.authToken}`) + console.debug(`url: ${message.siteUrl}`) + console.debug(`token: ${message.authToken}`) const { options } = await chrome.storage.sync.get(['options']) if ( options?.siteUrl !== message.siteUrl || @@ -169,7 +171,7 @@ async function onMessage(message) { authToken: message.authToken, } await chrome.storage.local.set({ auth }) - console.log('New Authentication Found.') + console.info('New Authentication Found.') if (options.checkAuth) { alwaysAuth.classList.remove('d-none') } @@ -189,8 +191,8 @@ async function saveOptions(event) { // console.log('saveOptions:', event) const { options } = await chrome.storage.sync.get(['options']) options[event.target.id] = event.target.checked - console.log(`Set: "${event.target.id}" to target:`, event.target) - console.log('options:', options) + console.info(`Set: "${event.target.id}" to target:`, event.target) + console.debug('options:', options) await chrome.storage.sync.set({ options }) if (event.target.id === 'popupPreview') { if (event.target.checked) { @@ -213,15 +215,15 @@ async function saveOptions(event) { * @param {MouseEvent} event */ async function authCredentials(event) { - console.log('authCredentials:', event) + console.debug('authCredentials:', event) const { auth } = await chrome.storage.local.get(['auth']) - console.log('auth:', auth) + console.debug('auth:', auth) if (auth?.authToken && auth?.siteUrl) { const { options } = await chrome.storage.sync.get(['options']) options.authToken = auth.authToken options.siteUrl = auth.siteUrl await chrome.storage.sync.set({ options }) - console.log('Auth Credentials Updated...') + console.info('Auth Credentials Updated...') authButton.classList.add('d-none') errorAlert.classList.add('d-none') alwaysAuth.classList.add('d-none') @@ -238,7 +240,7 @@ async function authCredentials(event) { * @param {Number} rows */ function genLoadingData(rows) { - console.log('genLoadingData:', rows) + console.debug('genLoadingData:', rows) const number = parseInt(rows, 10) if (number > 0) { filesTable.classList.remove('d-none') @@ -264,19 +266,19 @@ function genLoadingData(rows) { * @param {Array} data */ function updateTable(data) { - console.log('updateTable:', data) + console.debug('updateTable:', data) const tbody = filesTable.querySelector('tbody') const length = tbody.rows.length - // console.log(`data.length: ${data.length}`) - // console.log(`tbody.rows.length: ${tbody.rows.length}`) + // console.debug(`data.length: ${data.length}`) + // console.debug(`tbody.rows.length: ${tbody.rows.length}`) for (let i = 0; i < length; i++) { - // console.log(`i: ${i}`) + // console.debug(`i: ${i}`) let row = tbody.rows[i] if (!row) { row = tbody.insertRow() } if (data.length === i) { - console.log('End of data. Removing remaining rows...') + console.info('End of data. Removing remaining rows...') const rowsToRemove = length - i for (let j = 0; j < rowsToRemove; j++) { tbody.deleteRow(tbody.rows.length - 1) @@ -286,26 +288,43 @@ function updateTable(data) { const value = data[i] // TODO: This should not happen because of above condition if (!value) { - console.warn(`No Data Value at Index: ${i}`, row) + console.error(`No Data Value at Index: ${i}`, row) continue } // TODO: This throws an error if value is not valid URL const url = new URL(value) const name = url.pathname.replace(/^\/u\//, '') - - const copy = document.createElement('a') - copy.title = 'Copy' - copy.setAttribute('role', 'button') - copy.classList.add('link-body-emphasis', 'clip') - copy.innerHTML = '' - copy.dataset.clipboardText = value - copy.addEventListener('click', clipClick) + const raw = url.origin + url.pathname.replace(/^\/u\//, '/raw/') + + // Menu Button -> 0 + const menu = document.createElement('a') + menu.title = 'Menu' + menu.classList.add('link-body-emphasis') + menu.setAttribute('role', 'button') + menu.setAttribute('aria-expanded', 'false') + menu.dataset.bsToggle = 'dropdown' + menu.innerHTML = '' + + // Drop Down -> Menu + const drop = document + .querySelector('.d-none .dropdown-menu') + .cloneNode(true) + const dropText = drop.querySelector('.text-break') + dropText.textContent = name + dropText.dataset.raw = raw + dropText.dataset.clipboardText = name + drop.querySelector('[data-action="copy"]').dataset.clipboardText = value + drop.querySelector('[data-action="raw"]').dataset.clipboardText = raw + menu.appendChild(drop) + + // Cell: 0 const cell0 = row.cells[0] cell0.classList.add('align-middle') cell0.style.width = '20px' cell0.innerHTML = '' - cell0.appendChild(copy) + cell0.appendChild(menu) + // File Link -> 1 const link = document.createElement('a') link.text = name link.title = name @@ -315,52 +334,44 @@ function updateTable(data) { 'link-underline', 'link-underline-opacity-0', 'link-underline-opacity-75-hover', - 'file-link' + 'file-link', + 'mouse-link' ) link.target = '_blank' link.dataset.name = name - link.dataset.raw = - url.origin + - url.pathname.replace(/^\/u\//, '/raw/') + - '?view=gallery' + // link.dataset.row = i.toString() + link.id = `file-${i}` + link.dataset.raw = `${raw}?view=gallery` + + // Cell: 0 const cell1 = row.cells[1] cell1.classList.add('text-break') cell1.innerHTML = '' cell1.appendChild(link) - - const del = document.createElement('a') - del.title = 'Delete' - del.setAttribute('role', 'button') - del.classList.add('link-danger') - del.innerHTML = '' - del.addEventListener('click', deleteClick) - const cell2 = row.cells[2] - cell2.classList.add('align-middle') - cell2.style.width = '20px' - cell2.innerHTML = '' - cell2.appendChild(del) } } /** - * Delete Click Callback - * @function deleteClick + * Context Menu Click Callback + * @function ctxMenu * @param {MouseEvent} event */ -async function deleteClick(event) { - console.log('deleteClick:', event) - const closest = event.target?.closest('tr')?.querySelector('.file-link') - const name = closest.dataset?.name - console.log('name:', name) - if (!name) { - return console.error('No name for: event, closest', event, closest) - } - deleteName.textContent = name - const { options } = await chrome.storage.sync.get(['options']) - if (options.deleteConfirm) { - deleteModal.show() - } else { - await deleteConfirm(event) +async function ctxMenu(event) { + console.debug('ctxMenu:', event) + event.preventDefault() + const anchor = event.target.closest('a') + // console.log('anchor:', anchor) + console.log('action:', anchor.dataset?.action) + const file = event.target?.closest('tr')?.querySelector('.file-link') + console.log('name:', file.dataset?.name) + if (anchor.dataset?.action === 'delete') { + deleteName.textContent = file.dataset?.name + const { options } = await chrome.storage.sync.get(['options']) + if (options.deleteConfirm) { + deleteModal.show() + } else { + await deleteConfirm(event) + } } } @@ -370,31 +381,31 @@ async function deleteClick(event) { * @param {MouseEvent} event */ async function deleteConfirm(event) { - console.log('deleteConfirm:', event) + console.debug('deleteConfirm:', event) const name = deleteName.textContent - console.log(`Deleting File: ${name}`) + console.log(`deleteConfirm await deleteFile: ${name}`) // TODO: Catch Error? Throw should happen during init... const response = await deleteFile(name) - console.log('response:', response) + console.debug('response:', response) if (response.ok) { mediaOuter.classList.add('d-none') deleteModal.hide() await initPopup() } else { - console.error(`Error Deleting File: "${name}", response:`, response) + console.info(`Error Deleting File: "${name}", response:`, response) showToast(`Error Deleting: ${name}`, 'danger') deleteModal.hide() } } /** - * Post URL to endpoint + * Delete File Request * @function deleteFile * @param {String} name * @return {Response} */ async function deleteFile(name) { - console.log(`deleteFile: ${name}`) + console.debug(`deleteFile: ${name}`) const { options } = await chrome.storage.sync.get(['options']) // console.log('options:', options) const headers = { Authorization: options.authToken } @@ -402,7 +413,7 @@ async function deleteFile(name) { method: 'DELETE', headers: headers, } - const apiUrl = `${options.siteUrl}/api/delete/${name}` + const apiUrl = `${options.siteUrl}/api/file/${name}` return await fetch(apiUrl, opts) } @@ -421,27 +432,6 @@ function showToast(message, type = 'success') { const toast = new bootstrap.Toast(element) element.addEventListener('mouseover', () => toast.hide()) toast.show() - // const callback = () => { - // element.addEventListener('mouseover', () => toast.hide()) - // } - // setTimeout(callback, 1000) -} - -/** - * Clipboard Click Callback - * @function clipClick - * @param {MouseEvent} event - */ -function clipClick(event) { - console.log('clipClick:', event) - const element = event.target.closest('a') - // console.log('element:', element) - element.classList.add('link-success') - element.classList.remove('link-body-emphasis') - setTimeout(() => { - element.classList.add('link-body-emphasis') - element.classList.remove('link-success') - }, 500) } /** @@ -464,12 +454,13 @@ function displayAlert({ message, type = 'warning', auth = false } = {}) { } async function checkSiteAuth() { + console.debug('checkSiteAuth') try { const [tab] = await chrome.tabs.query({ currentWindow: true, active: true, }) - console.log('tab:', tab) + console.debug('tab:', tab) await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['/js/auth.js'], @@ -478,7 +469,7 @@ async function checkSiteAuth() { } function initPopupMouseover() { - console.log('initPopupMouseover') + console.debug('initPopupMouseover') mediaOuter.addEventListener('mouseover', () => { mediaOuter.classList.add('d-none') mediaImage.src = loadingImage @@ -486,13 +477,13 @@ function initPopupMouseover() { clearTimeout(timeoutID) } }) - mediaImage.addEventListener('error', () => { - // console.log('mediaError:', event) + mediaImage.addEventListener('error', (event) => { + console.debug('mediaError:', event) mediaImage.classList.add('d-none') mediaError.classList.remove('d-none') mediaImage.src = '../media/loading.gif' }) - document.querySelectorAll('.file-link').forEach((el) => { + document.querySelectorAll('.mouse-link').forEach((el) => { el.addEventListener('mouseover', onMouseOver) el.addEventListener('mouseout', onMouseOut) }) @@ -509,8 +500,6 @@ function onMouseOver(event) { mediaOuter.classList.remove('bottom-0') mediaOuter.classList.add('top-0') } - // console.log('name:', event.target.innerText) - // console.log('raw:', event.target.dataset.raw) const str = event.target.innerText const imageExtensions = /\.(gif|ico|jpeg|jpg|png|svg|webp)$/i if (str.match(imageExtensions)) { From 0a29c80664e0501f051ccee62e35a0fc5984c3df Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Sun, 24 Dec 2023 18:03:05 -0800 Subject: [PATCH 03/15] Update Context Menu --- src/css/popup.css | 5 ++ src/html/popup.html | 26 +++++--- src/js/popup.js | 145 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 142 insertions(+), 34 deletions(-) diff --git a/src/css/popup.css b/src/css/popup.css index 6ee0bd5..9f1898e 100644 --- a/src/css/popup.css +++ b/src/css/popup.css @@ -17,3 +17,8 @@ body { #toast-container { z-index: 4; } + +ul.dropdown-menu { + max-width: 320px; + min-width: 200px; +} diff --git a/src/html/popup.html b/src/html/popup.html index 353a3b1..7fffd62 100644 --- a/src/html/popup.html +++ b/src/html/popup.html @@ -73,15 +73,23 @@

Django Files

-
+ + +
+ +
+ diff --git a/src/js/popup.js b/src/js/popup.js index ab8a76a..17153ce 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -2,6 +2,7 @@ chrome.runtime.onMessage.addListener(onMessage) document.addEventListener('DOMContentLoaded', initPopup) +document.getElementById('expire-form').addEventListener('submit', expireForm) document .getElementById('confirm-delete') .addEventListener('click', deleteConfirm) @@ -17,6 +18,12 @@ document document .querySelectorAll('[data-bs-toggle="tooltip"]') .forEach((el) => new bootstrap.Tooltip(el)) +document + .getElementById('expire-modal') + .addEventListener('shown.bs.modal', () => { + expireInput.focus() + expireInput.select() + }) const filesTable = document.getElementById('files-table') const errorAlert = document.getElementById('error-alert') @@ -26,7 +33,10 @@ const mediaOuter = document.getElementById('media-outer') const mediaImage = document.getElementById('media-image') const mediaError = document.getElementById('media-error') const deleteName = document.getElementById('delete-name') +const ctxMenuRow = document.getElementById('ctx-menu-row') +const expireInput = document.getElementById('expire-input') const deleteModal = bootstrap.Modal.getOrCreateInstance('#delete-modal') +const expireModal = bootstrap.Modal.getOrCreateInstance('#expire-modal') const clipboard = new ClipboardJS('.clip') clipboard.on('success', () => showToast('Copied to Clipboard')) @@ -36,6 +46,7 @@ const loadingImage = '../media/loading.gif' let authError = false let timeoutID let timeout +let fileData /** * Initialize Popup @@ -91,12 +102,11 @@ async function initPopup() { cache: 'no-cache', } let response - let data try { const url = new URL(`${options.siteUrl}/api/recent/`) url.searchParams.append('amount', options.recentFiles || '10') response = await fetch(url, opts) - data = await response.json() + fileData = await response.json() } catch (e) { console.warn(e) return displayAlert({ @@ -105,22 +115,26 @@ async function initPopup() { auth: true, }) } - console.debug(`response.status: ${response.status}`, response, data) + console.debug(`response.status: ${response.status}`, response, fileData) // Check response data is valid and has files if (!response?.ok) { - console.warn(`error: ${data.error}`) - return displayAlert({ message: data.error, type: 'danger', auth: true }) - } else if (data === undefined) { + console.warn(`error: ${fileData.error}`) + return displayAlert({ + message: fileData.error, + type: 'danger', + auth: true, + }) + } else if (fileData === undefined) { return displayAlert({ message: 'Response Data Undefined.', auth: true }) - } else if (!data.length) { + } else if (!fileData.length) { return displayAlert({ message: 'No Files Returned.' }) } // Update table should only be called here, changes should use initPopup() - updateTable(data, options) + updateTable(fileData, options) document - .querySelectorAll('.dropdown-item') + .querySelectorAll('.ctx') .forEach((el) => el.addEventListener('click', ctxMenu)) // Enable Popup Mouseover Preview if popupPreview @@ -292,6 +306,7 @@ function updateTable(data, options) { } break } + row.id = `row-${i}` // TODO: Backwards Compatible with Older DJ Versions let url let name @@ -324,6 +339,7 @@ function updateTable(data, options) { const drop = document .querySelector('.d-none .dropdown-menu') .cloneNode(true) + drop.id = `ctx-${i}` if (typeof data[i] === 'object') { updateContextMenu(drop, data[i]).then() } @@ -358,6 +374,7 @@ function updateTable(data, options) { ) link.target = '_blank' link.dataset.name = name + link.dataset.row = i link.dataset.raw = `${raw}?token=${options.authToken}&view=gallery` // Cell: 1 @@ -394,6 +411,9 @@ async function updateContextMenu(ctx, data) { if (data.expr) { enableEl(ctx, '.fa-hourglass-start') ctx.querySelector('.expr-text').innerText = data.expr + } else { + disableEl(ctx, '.fa-hourglass-start') + ctx.querySelector('.expr-text').innerText = '' } } @@ -403,6 +423,12 @@ function enableEl(ctx, selector, add = 'text-body-emphasis') { el.classList.add(add) } +function disableEl(ctx, selector, remove = 'text-body-emphasis') { + const el = ctx.querySelector(selector) + el.classList.remove(remove) + el.classList.add('text-body-tertiary') +} + /** * Context Menu Click Callback * @function ctxMenu @@ -414,16 +440,71 @@ async function ctxMenu(event) { const anchor = event.target.closest('a') // console.debug('anchor:', anchor) console.debug('action:', anchor.dataset?.action) - const file = event.target?.closest('tr')?.querySelector('.file-link') - console.debug('name:', file.dataset?.name) + const fileLink = event.target?.closest('tr')?.querySelector('.file-link') + console.debug('row:', fileLink.dataset?.row) + if (!fileLink.dataset?.row) { + console.error('404: fileLink.dataset?.row - Fatal Error!') + } + ctxMenuRow.value = fileLink.dataset?.row + const file = fileData[fileLink.dataset?.row] + console.debug('file:', file) + let name + if (typeof file === 'object') { + name = file.name + } else { + name = fileLink.dataset.name + } + console.debug('name:', name) if (anchor.dataset?.action === 'delete') { - deleteName.textContent = file.dataset?.name + deleteName.textContent = name const { options } = await chrome.storage.sync.get(['options']) if (options.deleteConfirm) { deleteModal.show() } else { await deleteConfirm(event) } + } else if (anchor.dataset?.action === 'expire') { + expireInput.value = file.expr + expireModal.show() + console.log('focus') + } +} + +/** + * Expire Form Submit Callback + * @function expireForm + * @param {SubmitEvent} event + */ +async function expireForm(event) { + console.debug('expireForm:', event) + event.preventDefault() + const file = fileData[ctxMenuRow.value] + console.debug('file:', file) + const expr = expireInput.value + if (expr === file.expr) { + console.log(`New Expire Value Same as Old: ${expr}`) + showToast(`New Expire same as Previous: ${file.name}`, 'warning') + return expireModal.hide() + } + console.log(`Setting Expire "${expr}" on file: ${file.name}`) + const data = { expr: expr } + // TODO: Catch Error? Throw should happen during init... + const response = await handleFile(file.name, 'POST', data) + console.debug('response:', response) + if (response.ok) { + mediaOuter.classList.add('d-none') + showToast(`Expire Updated: ${file.name}`) + const json = await response.json() + console.debug('json:', json) + const ctx = document.getElementById(`ctx-${ctxMenuRow.value}`) + console.debug('ctx:', ctx) + fileData[ctxMenuRow.value] = json + await updateContextMenu(ctx, json) + expireModal.hide() + } else { + console.info(`Error Setting Expire: "${expr}", response:`, response) + showToast(`Error Setting Expire: ${file.name}`, 'danger') + expireModal.hide() } } @@ -434,6 +515,9 @@ async function ctxMenu(event) { */ async function deleteConfirm(event) { console.debug('deleteConfirm:', event) + event.preventDefault() + const file = fileData[ctxMenuRow.value] + console.debug('file:', file) const name = deleteName.textContent console.log(`deleteConfirm await deleteFile: ${name}`) // TODO: Catch Error? Throw should happen during init... @@ -444,8 +528,8 @@ async function deleteConfirm(event) { deleteModal.hide() await initPopup() } else { - console.info(`Error Deleting File: "${name}", response:`, response) - showToast(`Error Deleting: ${name}`, 'danger') + console.info(`Error Deleting: "${name}", response:`, response) + showToast(`Error Deleting: ${name}`, 'danger') deleteModal.hide() } } @@ -468,11 +552,11 @@ async function handleFile(name, method, data = null) { headers: headers, } if (data) { - opts.data = JSON.stringify(data) + opts.body = JSON.stringify(data) } // TODO: Update to /file/ Endpoint... - // const apiUrl = `${options.siteUrl}/api/file/${name}` - const apiUrl = `${options.siteUrl}/api/delete/${name}` + const apiUrl = `${options.siteUrl}/api/file/${name}` + // const apiUrl = `${options.siteUrl}/api/delete/${name}` return await fetch(apiUrl, opts) } From 5527d7ccffb0d969c7c2c76fbcb1d4e82bd64e0a Mon Sep 17 00:00:00 2001 From: Shane <6071159+smashedr@users.noreply.github.com> Date: Mon, 25 Dec 2023 21:36:51 -0800 Subject: [PATCH 10/15] Add Password and Private --- src/html/popup.html | 38 ++++++++++--- src/js/popup.js | 128 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 134 insertions(+), 32 deletions(-) diff --git a/src/html/popup.html b/src/html/popup.html index 36a1b81..e67a0d9 100644 --- a/src/html/popup.html +++ b/src/html/popup.html @@ -81,16 +81,17 @@

Django Files

  • Open Raw File
  • -
  • +
  • Delete File
  • @@ -110,9 +111,9 @@

    Django Files

    @@ -136,9 +137,30 @@

    Django Files

    + + + + +