- revisit later; avatar, WYSIWYG

This commit is contained in:
Ty Clifford
2026-06-19 12:03:13 -04:00
parent 25327e3302
commit d83b62b79c
6 changed files with 15 additions and 175 deletions
+3 -95
View File
@@ -7,7 +7,6 @@ $errors = [];
if (isPost()) {
csrfCheck();
$em = ps('email'); $pw = ps('password'); $pw2 = ps('password2');
$avatarPath = (string)($u['avatar_path'] ?? '');
if ($em && !filter_var($em,FILTER_VALIDATE_EMAIL)) $errors[] = 'Invalid email address.';
if ($pw) {
if (strlen($pw) < 6) $errors[] = 'Password must be at least 6 characters.';
@@ -15,18 +14,11 @@ if (isPost()) {
}
if (!$errors && $em && $em !== $u['email'] && qval("SELECT id FROM users WHERE email=? AND id!=?",[$em,uid()])) $errors[] = 'Email already in use.';
if (!$errors) {
if (p('remove_avatar','0') === '1') $avatarPath = '';
$avatar = storeAvatarUpload('avatar', mbToBytes(setting('avatar_upload_max_mb','3'), 3));
if (!$avatar['ok']) $errors[] = $avatar['error'];
elseif (!empty($avatar['path'])) $avatarPath = (string)$avatar['path'];
}
if (!$errors) {
if ($pw) qrun("UPDATE users SET email=?,avatar_path=?,password=? WHERE id=?",[$em?:null,$avatarPath,password_hash($pw,PASSWORD_BCRYPT),uid()]);
else qrun("UPDATE users SET email=?,avatar_path=? WHERE id=?",[$em?:null,$avatarPath,uid()]);
if ($pw) qrun("UPDATE users SET email=?,password=? WHERE id=?",[$em?:null,password_hash($pw,PASSWORD_BCRYPT),uid()]);
else qrun("UPDATE users SET email=? WHERE id=?",[$em?:null,uid()]);
flash('Account updated successfully!','success'); go('/account.php');
}
$u['email'] = $em;
$u['avatar_path'] = $avatarPath;
}
include __DIR__.'/includes/header.php';
?>
@@ -35,35 +27,13 @@ include __DIR__.'/includes/header.php';
<p class="form-sub">Manage your Discover Keyser WV account</p>
<div class="ibox" style="margin-bottom:1.5rem">
<div class="ibox-title">ACCOUNT INFO</div>
<div class="account-avatar-preview"><?=userAvatarHtml($u, 'avatar-lg')?></div>
<div class="irow"><span class="irow-i">👤</span><span class="irow-v"><?=e($u['username'])?> <?=isAdmin()?'<span class="badge badge-gold">ADMIN</span>':''?></span></div>
<div class="irow"><span class="irow-i">📅</span><span class="irow-v">Joined <?=fdate($u['created_at'])?></span></div>
<?php if($u['last_login']): ?><div class="irow"><span class="irow-i">🕐</span><span class="irow-v">Last login: <?=ago($u['last_login'])?></span></div><?php endif; ?>
</div>
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
<form method="POST" enctype="multipart/form-data">
<form method="POST">
<?=csrfField()?>
<div class="fg">
<label class="flabel">PROFILE AVATAR</label>
<div class="avatar-upload-grid">
<canvas id="avatarCanvas" class="avatar-canvas" width="240" height="240" aria-label="Avatar crop preview"></canvas>
<div>
<input type="file" name="avatar" id="avatarInput" class="finput" accept="image/*">
<div class="avatar-crop-controls" id="avatarCropControls">
<label>Zoom <input type="range" id="avatarZoom" min="1" max="3" step="0.01" value="1"></label>
<label>Horizontal <input type="range" id="avatarFocusX" min="0" max="100" value="50"></label>
<label>Vertical <input type="range" id="avatarFocusY" min="0" max="100" value="50"></label>
</div>
<input type="hidden" name="avatar_crop_x" id="avatarCropX" value="0">
<input type="hidden" name="avatar_crop_y" id="avatarCropY" value="0">
<input type="hidden" name="avatar_crop_size" id="avatarCropSize" value="0">
<div class="fhint">Optional JPG, PNG, GIF, or WebP. Max <?=e(setting('avatar_upload_max_mb','3'))?> MB.</div>
<?php if(!empty($u['avatar_path'])): ?>
<label style="display:flex;align-items:center;gap:.45rem;margin-top:.6rem;font-size:.85rem;color:var(--text-m);cursor:pointer"><input type="checkbox" name="remove_avatar" value="1"> Remove current avatar</label>
<?php endif; ?>
</div>
</div>
</div>
<div class="fg"><label class="flabel">EMAIL ADDRESS</label><input type="email" name="email" class="finput" value="<?=e($u['email']??'')?>"></div>
<hr class="divider" style="margin:1.25rem 0">
<p style="font-family:'Oswald',sans-serif;font-size:.72rem;letter-spacing:.1em;color:var(--gold);margin-bottom:.85rem">CHANGE PASSWORD (leave blank to keep current)</p>
@@ -72,66 +42,4 @@ include __DIR__.'/includes/header.php';
<button type="submit" class="btn btn-primary btn-block">Save Changes</button>
</form>
</div>
<?php $footExtra = <<<'HTML'
<script>
(function(){
const input = document.getElementById('avatarInput');
const canvas = document.getElementById('avatarCanvas');
if (!input || !canvas) return;
const ctx = canvas.getContext('2d');
const zoom = document.getElementById('avatarZoom');
const fx = document.getElementById('avatarFocusX');
const fy = document.getElementById('avatarFocusY');
const cx = document.getElementById('avatarCropX');
const cy = document.getElementById('avatarCropY');
const cs = document.getElementById('avatarCropSize');
let img = null;
function drawEmpty(){
ctx.clearRect(0,0,240,240);
ctx.fillStyle = '#121f38';
ctx.fillRect(0,0,240,240);
ctx.strokeStyle = 'rgba(38,244,255,.35)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(120,120,104,0,Math.PI*2);
ctx.stroke();
}
function redraw(){
if (!img) { drawEmpty(); return; }
const z = parseFloat(zoom.value || '1');
const natural = Math.min(img.naturalWidth, img.naturalHeight);
const size = Math.max(1, Math.round(natural / z));
const x = Math.round((img.naturalWidth - size) * (parseInt(fx.value,10) / 100));
const y = Math.round((img.naturalHeight - size) * (parseInt(fy.value,10) / 100));
cx.value = x; cy.value = y; cs.value = size;
ctx.clearRect(0,0,240,240);
ctx.save();
ctx.beginPath();
ctx.arc(120,120,118,0,Math.PI*2);
ctx.clip();
ctx.drawImage(img, x, y, size, size, 0, 0, 240, 240);
ctx.restore();
ctx.strokeStyle = 'rgba(255,209,102,.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(120,120,118,0,Math.PI*2);
ctx.stroke();
}
input.addEventListener('change', function(){
const file = input.files && input.files[0];
if (!file) { img = null; redraw(); return; }
const reader = new FileReader();
reader.onload = function(){
img = new Image();
img.onload = redraw;
img.src = reader.result;
};
reader.readAsDataURL(file);
});
[zoom, fx, fy].forEach(el => el && el.addEventListener('input', redraw));
drawEmpty();
})();
</script>
HTML; ?>
<?php include __DIR__.'/includes/footer.php'; ?>
+1 -5
View File
@@ -18,7 +18,6 @@ if (isPost()) {
setSetting('event_upload_max_mb', (string)max(1, (float)p('event_upload_max_mb','5')));
setSetting('menu_item_image_limit', (string)max(0, min(1, (int)p('menu_item_image_limit','1'))));
setSetting('menu_upload_max_mb', (string)max(1, (float)p('menu_upload_max_mb','5')));
setSetting('avatar_upload_max_mb', (string)max(1, (float)p('avatar_upload_max_mb','3')));
setSetting('forum_image_resize_threshold_mb', (string)max(0.5, (float)p('forum_image_resize_threshold_mb','1')));
setSetting('forum_image_max_width', (string)max(640, min(3200, (int)p('forum_image_max_width','1600'))));
setSetting('forum_image_quality', (string)max(40, min(95, (int)p('forum_image_quality','82'))));
@@ -68,7 +67,6 @@ $mailgunHasKey = setting('mailgun_api_key','') !== '';
$eventUploadMax = setting('event_upload_max_mb','5');
$menuImageLimit = setting('menu_item_image_limit','1');
$menuUploadMax = setting('menu_upload_max_mb','5');
$avatarUploadMax = setting('avatar_upload_max_mb','3');
$resizeThreshold = setting('forum_image_resize_threshold_mb','1');
$resizeMaxWidth = setting('forum_image_max_width','1600');
$resizeQuality = setting('forum_image_quality','82');
@@ -148,7 +146,7 @@ include __DIR__.'/admin_header.php';
<input type="hidden" name="home_random_interval" value="<?=e($homeRandomInterval)?>">
<div class="form-row">
<div class="fg"><label class="flabel">EVENT IMAGE MAX MB</label><input type="number" name="event_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($eventUploadMax)?>"></div>
<div class="fg"><label class="flabel">AVATAR MAX MB</label><input type="number" name="avatar_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($avatarUploadMax)?>"></div>
<div class="fg"><label class="flabel">MENU IMAGE MAX MB</label><input type="number" name="menu_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($menuUploadMax)?>"></div>
</div>
<div class="form-row">
<div class="fg">
@@ -158,7 +156,6 @@ include __DIR__.'/admin_header.php';
<option value="0"<?=$menuImageLimit==='0'?' selected':''?>>No menu item images</option>
</select>
</div>
<div class="fg"><label class="flabel">MENU IMAGE MAX MB</label><input type="number" name="menu_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($menuUploadMax)?>"></div>
</div>
<hr class="divider" style="margin:1rem 0">
<div class="form-row">
@@ -182,7 +179,6 @@ include __DIR__.'/admin_header.php';
<input type="hidden" name="event_upload_max_mb" value="<?=e($eventUploadMax)?>">
<input type="hidden" name="menu_item_image_limit" value="<?=e($menuImageLimit)?>">
<input type="hidden" name="menu_upload_max_mb" value="<?=e($menuUploadMax)?>">
<input type="hidden" name="avatar_upload_max_mb" value="<?=e($avatarUploadMax)?>">
<input type="hidden" name="forum_image_resize_threshold_mb" value="<?=e($resizeThreshold)?>">
<input type="hidden" name="forum_image_max_width" value="<?=e($resizeMaxWidth)?>">
<input type="hidden" name="forum_image_quality" value="<?=e($resizeQuality)?>">
+3 -13
View File
@@ -416,17 +416,7 @@ address{font-style:normal}
.text-gold{color:var(--gold)}.text-muted{color:var(--text-m)}.text-dim{color:var(--text-d)}
.text-right{text-align:right}.text-center{text-align:center}
/* ── Avatars / Profile Images ───────────────────────────── */
.avatar{display:inline-grid;place-items:center;border-radius:50%;object-fit:cover;flex-shrink:0;border:1px solid rgba(38,244,255,.32);background:linear-gradient(135deg,rgba(38,244,255,.18),rgba(255,79,216,.14));color:var(--cyan-l);font-family:'Oswald',sans-serif;font-weight:600;letter-spacing:.04em;box-shadow:0 0 14px rgba(38,244,255,.08)}
.avatar-xs{width:24px;height:24px;font-size:.7rem}
.avatar-sm{width:38px;height:38px;font-size:.9rem}
.avatar-lg{width:96px;height:96px;font-size:2rem}
.account-avatar-preview{display:flex;justify-content:center;margin:.25rem 0 1rem}
.avatar-upload-grid{display:grid;grid-template-columns:240px 1fr;gap:1rem;align-items:start}
.avatar-canvas{width:240px;height:240px;border-radius:50%;border:1px solid rgba(38,244,255,.34);background:var(--bg4);box-shadow:0 0 22px rgba(38,244,255,.08)}
.avatar-crop-controls{display:grid;gap:.5rem;margin-top:.85rem}
.avatar-crop-controls label{display:grid;gap:.2rem;font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.1em;color:var(--text-d)}
.avatar-crop-controls input[type=range]{width:100%;accent-color:var(--cyan)}
/* ── Reviews ───────────────────────────────────────────── */
.rev-user{display:flex;align-items:center;gap:.7rem}
/* ── Menu images ────────────────────────────────────────── */
@@ -438,7 +428,7 @@ address{font-style:normal}
/* ── Forum ──────────────────────────────────────────────── */
.forum-layout{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:2rem;align-items:start}
.forum-topic-list{display:grid;gap:.8rem}
.forum-topic-row{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:.85rem;align-items:center;padding:1rem;background:linear-gradient(180deg,rgba(18,31,56,.86),rgba(9,15,30,.92));border:1px solid rgba(38,244,255,.16);border-radius:var(--r);color:var(--text);transition:all var(--t)}
.forum-topic-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;padding:1rem;background:linear-gradient(180deg,rgba(18,31,56,.86),rgba(9,15,30,.92));border:1px solid rgba(38,244,255,.16);border-radius:var(--r);color:var(--text);transition:all var(--t)}
.forum-topic-row:hover{border-color:rgba(38,244,255,.44);box-shadow:var(--sh-sm);transform:translateY(-1px)}
.forum-topic-main{min-width:0}
.forum-topic-main strong{display:block;color:var(--text);font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -468,7 +458,7 @@ address{font-style:normal}
/* ── Responsive ─────────────────────────────────────────── */
@media(max-width:1100px){.footer-grid{grid-template-columns:1fr 1fr}.home-hero-grid{grid-template-columns:1fr}.hero-signal{grid-template-columns:1fr 1fr}.signal-card-main{min-height:auto}.about-split{grid-template-columns:1fr}}
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.avatar-upload-grid{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}}
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}}
@media(max-width:768px){
.nav-links{display:none;position:absolute;top:64px;left:0;right:0;background:var(--bg0);border-bottom:1px solid var(--bdr-l);flex-direction:column;padding:.75rem 1rem;gap:.1rem;box-shadow:var(--sh)}
.nav-links.open{display:flex}.hamburger{display:flex}
+3 -4
View File
@@ -136,7 +136,7 @@ $pageTitle = e($b['name']).' — Keyser, WV';
$activeNav = 'dir';
$reviews = qall("
SELECT r.*, u.username, u.avatar_path
SELECT r.*, u.username
FROM reviews r JOIN users u ON u.id = r.user_id
WHERE r.business_id = ? AND r.is_approved = 1
ORDER BY r.created_at DESC
@@ -465,10 +465,9 @@ include __DIR__.'/includes/header.php';
<div class="rev-card">
<div class="rev-hdr">
<div class="rev-user">
<?=userAvatarHtml($r, 'avatar-sm')?>
<div>
<div class="rev-author"><?=e($r['username'] ?? 'Anonymous')?></div>
<?=starDisplay((float)($r['rating'] ?? 0))?>
<div class="rev-author"><?=e($r['username'] ?? 'Anonymous')?></div>
<?=starDisplay((float)($r['rating'] ?? 0))?>
</div>
</div>
<div class="flex-row" style="gap:.5rem">
+5 -6
View File
@@ -96,7 +96,7 @@ if ($topicSlug === '') {
$categories = qall("SELECT * FROM forum_categories WHERE is_active=1 ORDER BY sort_order,name");
$topics = qall("
SELECT ft.*, fc.name category_name, u.username, u.avatar_path,
SELECT ft.*, fc.name category_name, u.username,
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) AS reply_count,
COALESCE((SELECT MAX(fr.created_at) FROM forum_replies fr WHERE fr.topic_id=ft.id), ft.created_at) AS last_activity
FROM forum_topics ft
@@ -130,7 +130,6 @@ if ($topicSlug === '') {
<div class="forum-topic-list">
<?php foreach($topics as $topic): ?>
<a href="<?=e(forumTopicUrl($topic))?>" class="forum-topic-row">
<?=userAvatarHtml($topic, 'avatar-sm')?>
<span class="forum-topic-main">
<strong><?=e($topic['title'])?></strong>
<em><?=e($topic['category_name'])?> by <?=e($topic['username'])?> · <?=ago($topic['last_activity'])?></em>
@@ -169,7 +168,7 @@ if ($topicSlug === '') {
}
$topic = qone("
SELECT ft.*, fc.name category_name, u.username, u.avatar_path
SELECT ft.*, fc.name category_name, u.username
FROM forum_topics ft
JOIN forum_categories fc ON fc.id=ft.category_id
JOIN users u ON u.id=ft.user_id
@@ -205,7 +204,7 @@ if (isPost() && ps('_act') === 'reply') {
$topicAttachments = qall("SELECT * FROM forum_attachments WHERE topic_id=? AND reply_id IS NULL ORDER BY created_at", [$topic['id']]);
$replies = qall("
SELECT fr.*, u.username, u.avatar_path
SELECT fr.*, u.username
FROM forum_replies fr
JOIN users u ON u.id=fr.user_id
WHERE fr.topic_id=?
@@ -223,7 +222,7 @@ include __DIR__.'/includes/header.php';
<div class="breadcrumb"><a href="/index.php">Home</a><span></span><a href="/forum">Forum</a><span></span><?=e($topic['title'])?></div>
<div class="cat-badge"><?=e($topic['category_name'])?></div>
<h1 style="font-size:clamp(2rem,4vw,2.9rem);margin:.45rem 0"><?=e($topic['title'])?></h1>
<div class="forum-byline"><?=userAvatarHtml($topic, 'avatar-xs')?> Posted by <?=e($topic['username'])?> · <?=ago($topic['created_at'])?><?php if($topic['is_locked']): ?> · <span class="badge badge-gray">Locked</span><?php endif; ?></div>
<div class="forum-byline">Posted by <?=e($topic['username'])?> · <?=ago($topic['created_at'])?><?php if($topic['is_locked']): ?> · <span class="badge badge-gray">Locked</span><?php endif; ?></div>
</div>
</div>
@@ -241,7 +240,7 @@ include __DIR__.'/includes/header.php';
<?php foreach($replies as $reply): ?>
<article class="forum-reply" id="reply-<?=$reply['id']?>">
<div class="forum-reply-meta">
<div class="forum-reply-author"><?=userAvatarHtml($reply, 'avatar-sm')?><span><?=e($reply['username'])?></span></div>
<div class="forum-reply-author"><span><?=e($reply['username'])?></span></div>
<a href="<?=e(forumTopicUrl($topic).'#reply-'.$reply['id'])?>" class="forum-permalink">#reply-<?=$reply['id']?></a>
</div>
<?php if($reply['body'] !== ''): ?><div class="forum-post-body"><?=forumText($reply['body'])?></div><?php endif; ?>
-52
View File
@@ -312,63 +312,11 @@ function storeMultipleUploads(string $field, string $subdir, array $allowedExts,
}
return ['ok'=>true, 'files'=>$saved];
}
function storeAvatarUpload(string $field, int $maxBytes): array {
$files = normalizeUploadFiles($field);
if (!$files || ($files[0]['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return ['ok'=>true, 'path'=>''];
$file = $files[0];
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) return ['ok'=>false, 'error'=>uploadErrorText((int)$file['error'])];
if ((int)($file['size'] ?? 0) > $maxBytes) return ['ok'=>false, 'error'=>'The avatar exceeds the configured size limit.'];
$ext = strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION));
if (!isImageExt($ext)) return ['ok'=>false, 'error'=>'Please choose a JPG, PNG, GIF, or WebP avatar.'];
if (!function_exists('getimagesize') || !function_exists('imagecreatetruecolor')) {
return storeUploadedFile($file, 'avatars', imageExts(), $maxBytes, ['image_only'=>true, 'resize_over_bytes'=>mbToBytes(1), 'image_max_width'=>512, 'image_quality'=>86]);
}
$info = @getimagesize((string)$file['tmp_name']);
if (!$info || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return ['ok'=>false, 'error'=>'The selected avatar is not a valid image.'];
$loader = match($info['mime']) {
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/gif' => 'imagecreatefromgif',
'image/webp' => 'imagecreatefromwebp',
default => '',
};
if ($loader === '' || !function_exists($loader)) return ['ok'=>false, 'error'=>'That avatar image type is not supported by this server.'];
$src = @$loader((string)$file['tmp_name']);
if (!$src) return ['ok'=>false, 'error'=>'The avatar could not be opened.'];
$w = (int)$info[0]; $h = (int)$info[1];
$size = (int)p('avatar_crop_size', min($w, $h));
$x = (int)p('avatar_crop_x', max(0, (int)(($w - $size) / 2)));
$y = (int)p('avatar_crop_y', max(0, (int)(($h - $size) / 2)));
$size = max(1, min($size, $w, $h));
$x = max(0, min($x, $w - $size));
$y = max(0, min($y, $h - $size));
$dst = imagecreatetruecolor(512, 512);
imagecopyresampled($dst, $src, 0, 0, $x, $y, 512, 512, $size, $size);
$dir = ensureUploadDir('avatars');
$filename = date('YmdHis').'-'.bin2hex(random_bytes(5)).'.jpg';
$dest = $dir.'/'.$filename;
$ok = imagejpeg($dst, $dest, 86);
imagedestroy($src);
imagedestroy($dst);
if (!$ok) return ['ok'=>false, 'error'=>'The cropped avatar could not be saved.'];
@chmod($dest, 0664);
return ['ok'=>true, 'path'=>uploadPublicPath('avatars', $filename)];
}
function formatBytes(int $bytes): string {
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB';
if ($bytes >= 1024) return number_format($bytes / 1024, 1).' KB';
return $bytes.' B';
}
function userAvatarHtml(?array $user, string $class = 'avatar-sm'): string {
$name = (string)($user['username'] ?? 'User');
$path = (string)($user['avatar_path'] ?? '');
if ($path !== '') return '<img src="'.e($path).'" alt="'.e($name).'" class="avatar '.$class.'">';
$initial = strtoupper(substr($name, 0, 1) ?: 'U');
return '<span class="avatar avatar-initial '.$class.'" aria-hidden="true">'.e($initial).'</span>';
}
function uniqueForumSlug(string $title, int $id): string {
$base = makeSlug($title) ?: 'topic';
return $base.'-'.$id;