Add image upload UI and server-side upload/list APIs

This commit is contained in:
dsyoon
2025-12-27 16:05:06 +09:00
parent 72d0eafd9e
commit dafeaa7c44
6 changed files with 424 additions and 43 deletions

33
api/list_images.php Normal file
View 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
View 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);

View File

@@ -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;

View File

@@ -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>

View File

@@ -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
View 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();
}
})();