Add image upload UI and server-side upload/list APIs
This commit is contained in:
33
api/list_images.php
Normal file
33
api/list_images.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
// Returns a JSON list of images currently present in /img
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$imgDir = realpath(__DIR__ . '/../img');
|
||||
if ($imgDir === false || !is_dir($imgDir)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'img directory not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$allowedExt = ['jpg','jpeg','png','gif','webp'];
|
||||
$images = [];
|
||||
|
||||
$files = scandir($imgDir);
|
||||
if ($files === false) $files = [];
|
||||
|
||||
foreach ($files as $f) {
|
||||
if ($f === '.' || $f === '..') continue;
|
||||
$path = $imgDir . DIRECTORY_SEPARATOR . $f;
|
||||
if (!is_file($path)) continue;
|
||||
$ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExt, true)) continue;
|
||||
$images[] = $f;
|
||||
}
|
||||
|
||||
// stable order
|
||||
natcasesort($images);
|
||||
$images = array_values($images);
|
||||
|
||||
echo json_encode(['ok' => true, 'images' => $images], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
|
||||
87
api/upload_image.php
Normal file
87
api/upload_image.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
// Upload handler: saves an uploaded image into /img and returns JSON.
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Limit: 10MB
|
||||
$maxBytes = 10 * 1024 * 1024;
|
||||
|
||||
if (!isset($_FILES['image'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'No file uploaded']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['image'];
|
||||
if (!is_array($file) || !isset($file['error'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Invalid upload']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Upload error: ' . $file['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($file['size']) || $file['size'] <= 0 || $file['size'] > $maxBytes) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'File too large (max 10MB)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Validate image content
|
||||
$tmp = $file['tmp_name'];
|
||||
$info = @getimagesize($tmp);
|
||||
if ($info === false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Not a valid image']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$mime = isset($info['mime']) ? strtolower($info['mime']) : '';
|
||||
$allowedMimeToExt = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
if (!isset($allowedMimeToExt[$mime])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['ok' => false, 'error' => 'Unsupported image type']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$imgDir = realpath(__DIR__ . '/../img');
|
||||
if ($imgDir === false || !is_dir($imgDir)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'img directory not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Generate safe unique filename
|
||||
$ext = $allowedMimeToExt[$mime];
|
||||
$base = 'upload_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4));
|
||||
$filename = $base . '.' . $ext;
|
||||
$dest = $imgDir . DIRECTORY_SEPARATOR . $filename;
|
||||
|
||||
// Ensure we don't overwrite (extremely unlikely)
|
||||
$tries = 0;
|
||||
while (file_exists($dest) && $tries < 5) {
|
||||
$filename = $base . '_' . bin2hex(random_bytes(2)) . '.' . $ext;
|
||||
$dest = $imgDir . DIRECTORY_SEPARATOR . $filename;
|
||||
$tries++;
|
||||
}
|
||||
|
||||
if (!move_uploaded_file($tmp, $dest)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'Failed to save file (check permissions)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Conservative permissions
|
||||
@chmod($dest, 0644);
|
||||
|
||||
echo json_encode(['ok' => true, 'filename' => $filename], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
|
||||
@@ -100,6 +100,84 @@ div.navigation {
|
||||
/* The navigation style is set using jQuery so that the javascript specific styles won't be applied unless javascript is enabled. */
|
||||
}
|
||||
|
||||
/* Upload panel under thumbnails */
|
||||
.upload-panel {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.upload-title {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dropzone {
|
||||
position: relative;
|
||||
border: 2px dashed #cfcfcf;
|
||||
border-radius: 8px;
|
||||
padding: 14px 12px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropzone:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(46, 131, 255, 0.25);
|
||||
border-color: #2e83ff;
|
||||
}
|
||||
.dropzone.dragover {
|
||||
border-color: #2e83ff;
|
||||
background: #f2f7ff;
|
||||
}
|
||||
.dropzone-text {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.file-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-preview {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.upload-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 160px;
|
||||
border: 1px solid #ddd;
|
||||
background: #fff;
|
||||
display: block;
|
||||
}
|
||||
.preview-meta {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.upload-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.upload-actions button {
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cfcfcf;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.upload-status {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ---- Layout: slideshow (left) + thumbs (right), full width ---- */
|
||||
.gallery-wrap {
|
||||
display: flex;
|
||||
|
||||
30
index.html
30
index.html
@@ -13,6 +13,7 @@
|
||||
<script type="text/javascript" src="js/jquery.opacityrollover.js"></script>
|
||||
<script type="text/javascript" src="js/bestpic.js"></script>
|
||||
<script type="text/javascript" src="js/image_manifest.js"></script>
|
||||
<script type="text/javascript" src="js/uploader.js"></script>
|
||||
|
||||
<!-- We only want the thunbnails to display when javascript is disabled -->
|
||||
<script type="text/javascript">
|
||||
@@ -41,23 +42,34 @@
|
||||
<div id="caption" class="caption-container"></div>
|
||||
</div>
|
||||
<div id="thumbs" class="navigation">
|
||||
<div id="thumbs-list">
|
||||
<ul class="thumbs noscript">
|
||||
<li>
|
||||
<a class="thumb" name="leaf" href="http://dreamgirl.ncue.net/img/0.jpg" title="Title #0">
|
||||
<!-- <img src="http://dreamgirl.ncue.net/img/0.jpg" alt="Title #0" width='75' height='75'/> -->
|
||||
</a>
|
||||
<div class="caption">
|
||||
<!--
|
||||
<div class="download">
|
||||
<a href="http://dreamgirl.ncue.net/img/0.jpg">Download</a>
|
||||
</div>
|
||||
<div class="image-title">Title #0</div>
|
||||
<div class="image-desc">Description</div>
|
||||
-->
|
||||
</div>
|
||||
<div class="caption"></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="upload-panel" class="upload-panel">
|
||||
<div class="upload-title">이미지 추가</div>
|
||||
<div id="dropzone" class="dropzone" tabindex="0">
|
||||
<div class="dropzone-text">
|
||||
드래그&드랍 또는 파일 선택
|
||||
</div>
|
||||
<input id="file-input" class="file-input" type="file" accept="image/*" />
|
||||
</div>
|
||||
<div id="upload-preview" class="upload-preview" style="display:none;">
|
||||
<img id="preview-img" alt="preview" />
|
||||
<div id="preview-meta" class="preview-meta"></div>
|
||||
</div>
|
||||
<div class="upload-actions">
|
||||
<button id="upload-btn" type="button" disabled>업로드</button>
|
||||
<span id="upload-status" class="upload-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,8 +42,8 @@ function onLoad() {
|
||||
exemptionSelector: '.selected'
|
||||
});
|
||||
|
||||
// Initialize Advanced Galleriffic Gallery
|
||||
$('#thumbs').galleriffic({
|
||||
// Initialize Advanced Galleriffic Gallery (store instance for uploader)
|
||||
window.dreamgirlGallery = $('#thumbs').galleriffic({
|
||||
delay: 2500,
|
||||
numThumbs: 15,
|
||||
preloadAhead: 10,
|
||||
@@ -96,10 +96,20 @@ function onLoad() {
|
||||
text += "</li>";
|
||||
}
|
||||
text += "</ul>";
|
||||
$("#thumbs").html(text);
|
||||
$("#thumbs-list").html(text);
|
||||
initGallery();
|
||||
}
|
||||
|
||||
function tryLoadFromLocalListApi() {
|
||||
return $.ajax({
|
||||
url: 'api/list_images.php',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
cache: false,
|
||||
timeout: 8000
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackToLocalManifest() {
|
||||
if (window.DREAMGIRL_IMAGES && window.DREAMGIRL_IMAGES.length) {
|
||||
buildThumbsFromList(window.DREAMGIRL_IMAGES);
|
||||
@@ -110,6 +120,17 @@ function onLoad() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prefer same-origin dynamic list (so newly uploaded images appear on refresh).
|
||||
tryLoadFromLocalListApi()
|
||||
.done(function(resp) {
|
||||
if (resp && resp.ok && resp.images && resp.images.length) {
|
||||
buildThumbsFromList(resp.images);
|
||||
} else {
|
||||
fallbackToLocalManifest();
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
// If local list API is unavailable, try remote JSONP API, then fall back to static manifest.
|
||||
$.ajax({
|
||||
url : getApiUrl(),
|
||||
type: 'GET',
|
||||
@@ -133,10 +154,10 @@ function onLoad() {
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
// If remote API fails (common on HTTPS if api host doesn't support it), fall back to local images.
|
||||
fallbackToLocalManifest();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
150
js/uploader.js
Normal file
150
js/uploader.js
Normal file
@@ -0,0 +1,150 @@
|
||||
(function () {
|
||||
function qs(id) { return document.getElementById(id); }
|
||||
|
||||
function humanSize(bytes) {
|
||||
if (!bytes && bytes !== 0) return '';
|
||||
var units = ['B', 'KB', 'MB', 'GB'];
|
||||
var i = 0;
|
||||
var n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||
return (Math.round(n * 10) / 10) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function buildThumbLi(filename) {
|
||||
var imgUrl = 'img/' + encodeURIComponent(filename);
|
||||
var title = filename;
|
||||
return (
|
||||
"<li>" +
|
||||
" <a class='thumb' name='leaf' href='" + imgUrl + "' title='" + title + "'>" +
|
||||
" <img src='" + imgUrl + "' alt='" + title + "' width='75' height='75'/>" +
|
||||
" </a>" +
|
||||
" <div class='caption'></div>" +
|
||||
"</li>"
|
||||
);
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
var el = qs('upload-status');
|
||||
if (el) el.textContent = text || '';
|
||||
}
|
||||
|
||||
function init() {
|
||||
var dropzone = qs('dropzone');
|
||||
var fileInput = qs('file-input');
|
||||
var previewWrap = qs('upload-preview');
|
||||
var previewImg = qs('preview-img');
|
||||
var previewMeta = qs('preview-meta');
|
||||
var uploadBtn = qs('upload-btn');
|
||||
|
||||
if (!dropzone || !fileInput || !uploadBtn) return;
|
||||
|
||||
var selectedFile = null;
|
||||
|
||||
function showPreview(file) {
|
||||
selectedFile = file;
|
||||
uploadBtn.disabled = !file;
|
||||
setStatus('');
|
||||
|
||||
if (!file) {
|
||||
if (previewWrap) previewWrap.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewWrap) previewWrap.style.display = 'block';
|
||||
if (previewMeta) previewMeta.textContent = file.name + ' · ' + humanSize(file.size);
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
if (previewImg) previewImg.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function isImageFile(file) {
|
||||
return file && file.type && file.type.indexOf('image/') === 0;
|
||||
}
|
||||
|
||||
function onFiles(files) {
|
||||
if (!files || !files.length) return;
|
||||
var f = files[0];
|
||||
if (!isImageFile(f)) {
|
||||
setStatus('이미지 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
showPreview(f);
|
||||
}
|
||||
|
||||
dropzone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add('dragover');
|
||||
});
|
||||
dropzone.addEventListener('dragleave', function () {
|
||||
dropzone.classList.remove('dragover');
|
||||
});
|
||||
dropzone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('dragover');
|
||||
onFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', function () {
|
||||
onFiles(fileInput.files);
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', function () {
|
||||
if (!selectedFile) return;
|
||||
uploadBtn.disabled = true;
|
||||
setStatus('업로드 중...');
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('image', selectedFile);
|
||||
|
||||
fetch('api/upload_image.php', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
throw new Error((data && data.error) ? data.error : '업로드 실패');
|
||||
}
|
||||
|
||||
// Add immediately to current gallery
|
||||
var filename = data.filename;
|
||||
if (window.dreamgirlGallery && typeof window.dreamgirlGallery.appendImage === 'function') {
|
||||
window.dreamgirlGallery.appendImage(buildThumbLi(filename));
|
||||
} else {
|
||||
// fallback: append to DOM
|
||||
var ul = document.querySelector('#thumbs ul.thumbs');
|
||||
if (ul) ul.insertAdjacentHTML('beforeend', buildThumbLi(filename));
|
||||
}
|
||||
|
||||
// keep in-memory list (doesn't persist; list_images.php handles persistence)
|
||||
if (window.DREAMGIRL_IMAGES && window.DREAMGIRL_IMAGES.push) {
|
||||
window.DREAMGIRL_IMAGES.push(filename);
|
||||
}
|
||||
|
||||
setStatus('업로드 완료: ' + filename);
|
||||
// reset
|
||||
selectedFile = null;
|
||||
if (fileInput) fileInput.value = '';
|
||||
if (previewWrap) previewWrap.style.display = 'none';
|
||||
})
|
||||
.catch(function (err) {
|
||||
setStatus('에러: ' + (err && err.message ? err.message : '업로드 실패'));
|
||||
})
|
||||
.finally(function () {
|
||||
uploadBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user