[FEAT] 完善個人檔案功能:新增頭像即時上傳、麵包屑導覽、版面寬度優化與日期格式統一
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 58s
This commit is contained in:
@@ -37,6 +37,22 @@ class ProfileController extends Controller
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's avatar via AJAX.
|
||||
*/
|
||||
public function updateAvatar(Request $request): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'avatar' => ['required', 'image', 'mimes:jpeg,png,jpg,gif', 'max:1024'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar if exists
|
||||
if ($user->avatar) {
|
||||
@@ -45,31 +61,19 @@ class ProfileController extends Controller
|
||||
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
$user->avatar = $path;
|
||||
$user->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'avatar_url' => $user->avatar_url,
|
||||
'message' => __('Avatar updated successfully.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => __('No file uploaded.'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,5 +151,10 @@
|
||||
"Day Before": "一昨日",
|
||||
"No login history yet": "ログイン履歴はまだありません",
|
||||
"Signed in as": "ログイン中",
|
||||
"Logout": "ログアウト"
|
||||
"Logout": "ログアウト",
|
||||
"Joined": "入会日",
|
||||
"Recent Login": "最近のログイン",
|
||||
"Total Logins": "総ログイン数",
|
||||
"Account Status": "アカウント状態",
|
||||
"Active": "アクティブ"
|
||||
}
|
||||
|
||||
@@ -151,5 +151,10 @@
|
||||
"Roles": "角色設定",
|
||||
"No login history yet": "尚無登入紀錄",
|
||||
"Signed in as": "登入身份",
|
||||
"Logout": "登出"
|
||||
"Logout": "登出",
|
||||
"Joined": "加入日期",
|
||||
"Recent Login": "最近登入",
|
||||
"Total Logins": "總登入次數",
|
||||
"Account Status": "帳號狀態",
|
||||
"Active": "使用中"
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="px-6 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">儲值回饋設定</h3>
|
||||
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="px-6 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">禮品設定</h3>
|
||||
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-luxury-primary">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
@section('content')
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg border border-gray-200">
|
||||
<div class="p-6 text-gray-900">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
@section('content')
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
<!-- 篩選器 -->
|
||||
<div class="luxury-card rounded-2xl p-6 animate-luxury-in">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
@section('content')
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="sm:px-6 lg:px-8 space-y-6">
|
||||
<!-- 基本資訊卡片 -->
|
||||
<div class="bg-white shadow sm:rounded-lg p-6 border border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">基本資訊</h3>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@section('content')
|
||||
@php
|
||||
@endphp
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="px-6 py-8">
|
||||
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">會員列表</h3>
|
||||
|
||||
<div class="mt-8">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="px-6 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">會員等級設定</h3>
|
||||
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-luxury-primary">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 text-center border border-gray-200 dark:border-gray-700">
|
||||
<div class="mb-6">
|
||||
<svg class="mx-auto h-24 w-24 text-gray-400 dark:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="container mx-auto px-6 py-8">
|
||||
<div class="px-6 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-gray-900 dark:text-gray-200 text-3xl font-medium">點數規則設定</h3>
|
||||
<button onclick="document.getElementById('createModal').classList.remove('hidden')" class="btn-luxury-primary">
|
||||
|
||||
176
resources/views/components/breadcrumbs.blade.php
Normal file
176
resources/views/components/breadcrumbs.blade.php
Normal file
@@ -0,0 +1,176 @@
|
||||
@props(['links' => null])
|
||||
|
||||
@php
|
||||
if (is_null($links)) {
|
||||
$routeName = Route::currentRouteName();
|
||||
$links = [];
|
||||
|
||||
// 預設首頁 (Dashboard)
|
||||
$links[] = [
|
||||
'label' => __('Dashboard'),
|
||||
'url' => route('admin.dashboard'),
|
||||
'active' => $routeName === 'admin.dashboard'
|
||||
];
|
||||
|
||||
if ($routeName && $routeName !== 'admin.dashboard') {
|
||||
// 定義大模組映射表 (路由前綴 => 大模組名稱)
|
||||
$moduleMap = [
|
||||
'profile' => __('Profile Settings'),
|
||||
'admin.members' => __('Member Management'),
|
||||
'admin.membership-tiers' => __('Member Management'),
|
||||
'admin.deposit-bonus-rules' => __('Member Management'),
|
||||
'admin.point-rules' => __('Member Management'),
|
||||
'admin.gift-definitions' => __('Member Management'),
|
||||
'admin.machines' => __('Machine Management'),
|
||||
'admin.app' => __('APP Management'),
|
||||
'admin.warehouses' => __('Warehouse Management'),
|
||||
'admin.sales' => __('Sales Management'),
|
||||
'admin.analysis' => __('Analysis Management'),
|
||||
'admin.audit' => __('Audit Management'),
|
||||
'admin.data-config' => __('Data Configuration'),
|
||||
'admin.remote' => __('Remote Management'),
|
||||
'admin.line' => __('Line Management'),
|
||||
'admin.reservation' => __('Reservation System'),
|
||||
'admin.special-permission' => __('Special Permission'),
|
||||
'admin.permission' => __('Permission Settings'),
|
||||
];
|
||||
|
||||
// 1. 找出所屬大模組
|
||||
$foundModule = null;
|
||||
foreach ($moduleMap as $prefix => $label) {
|
||||
if (str_starts_with($routeName, $prefix)) {
|
||||
$foundModule = [
|
||||
'label' => $label,
|
||||
'url' => '#',
|
||||
'active' => false
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($foundModule) {
|
||||
$links[] = $foundModule;
|
||||
}
|
||||
|
||||
// 2. 處理具體頁面
|
||||
$segments = explode('.', $routeName);
|
||||
$lastSegment = end($segments);
|
||||
|
||||
// 嘗試翻譯最後一個片段作為頁面名稱
|
||||
$pageLabel = match($lastSegment) {
|
||||
'edit' => __('Profile'), // 專門處理 profile.edit
|
||||
'index' => null, // 通常 index 代表列表,如果在大模組下則不重複顯示
|
||||
'logs' => __('Machine Logs'),
|
||||
'permissions' => __('Machine Permissions'),
|
||||
'utilization' => __('Utilization Rate'),
|
||||
'expiry' => __('Expiry Management'),
|
||||
'maintenance' => __('Maintenance Records'),
|
||||
'ui-elements' => __('UI Elements'),
|
||||
'helper' => __('Helper'),
|
||||
'questionnaire' => __('Questionnaire'),
|
||||
'games' => __('Games'),
|
||||
'timer' => __('Timer'),
|
||||
'personal' => __('Warehouse List (Individual)'),
|
||||
'stock-management' => __('Stock Management'),
|
||||
'transfers' => __('Transfers'),
|
||||
'purchases' => __('Purchases'),
|
||||
'replenishments' => __('Replenishments'),
|
||||
'replenishment-records' => __('Replenishment Records'),
|
||||
'machine-stock' => __('Machine Stock'),
|
||||
'staff-stock' => __('Staff Stock'),
|
||||
'returns' => __('Returns'),
|
||||
'pickup-codes' => __('Pickup Codes'),
|
||||
'orders' => __('Orders'),
|
||||
'promotions' => __('Promotions'),
|
||||
'pass-codes' => __('Pass Codes'),
|
||||
'store-gifts' => __('Store Gifts'),
|
||||
'change-stock' => __('Change Stock'),
|
||||
'machine-reports' => __('Machine Reports'),
|
||||
'product-reports' => __('Product Reports'),
|
||||
'survey-analysis' => __('Survey Analysis'),
|
||||
'products' => __('Product Management'),
|
||||
'advertisements' => __('Advertisement Management'),
|
||||
'admin-products' => __('Admin Sellable Products'),
|
||||
'accounts' => __('Account Management'),
|
||||
'sub-accounts' => __('Sub Accounts'),
|
||||
'sub-account-roles' => __('Sub Account Roles'),
|
||||
'points' => __('Point Settings'),
|
||||
'badges' => __('Badge Settings'),
|
||||
'restart' => __('Machine Restart'),
|
||||
'restart-card-reader' => __('Card Reader Restart'),
|
||||
'checkout' => __('Remote Checkout'),
|
||||
'lock' => __('Remote Lock'),
|
||||
'change' => __('Remote Change'),
|
||||
'dispense' => __('Remote Dispense'),
|
||||
'official-account' => __('Line Official Account'),
|
||||
'coupons' => __('Line Coupons'),
|
||||
'stores' => __('Store Management'),
|
||||
'time-slots' => __('Time Slots'),
|
||||
'venues' => __('Venue Management'),
|
||||
'reservations' => __('Reservations'),
|
||||
'clear-stock' => __('Clear Stock'),
|
||||
'apk-versions' => __('APK Versions'),
|
||||
'discord-notifications' => __('Discord Notifications'),
|
||||
'app-features' => __('APP Features'),
|
||||
'roles' => __('Roles'),
|
||||
'others' => __('Others'),
|
||||
'ai-prediction' => __('AI Prediction'),
|
||||
'create' => __('Create'),
|
||||
'show' => __('Show'),
|
||||
'members' => __('Member List'), // 處理 admin.members.index 這種情況
|
||||
default => null,
|
||||
};
|
||||
|
||||
// 如果匹配不到,嘗試處理一些特殊的 index 標籤
|
||||
if (!$pageLabel && $lastSegment === 'index') {
|
||||
$pageLabel = match($segments[count($segments)-2] ?? '') {
|
||||
'members' => __('Member List'),
|
||||
'machines' => __('Machine List'),
|
||||
'warehouses' => __('Warehouse List'),
|
||||
'sales' => __('Sales Records'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
if ($pageLabel) {
|
||||
$links[] = [
|
||||
'label' => $pageLabel,
|
||||
'url' => route($routeName),
|
||||
'active' => true
|
||||
];
|
||||
}
|
||||
|
||||
// 確保最後一個 link 是 active 的
|
||||
if (!empty($links)) {
|
||||
$links[count($links) - 1]['active'] = true;
|
||||
// 如果倒數第二個也是同個頁面(例如 Dashboard > Dashboard),則移除重複
|
||||
if (count($links) > 1 && $links[count($links)-1]['label'] === $links[count($links)-2]['label']) {
|
||||
array_pop($links);
|
||||
$links[count($links)-1]['active'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<nav {{ $attributes->merge(['class' => 'flex', 'aria-label' => 'Breadcrumb']) }}>
|
||||
<ol class="flex items-center whitespace-nowrap min-w-0 w-full">
|
||||
@foreach($links as $link)
|
||||
<li class="flex items-center text-sm {{ $link['active'] ? 'font-semibold text-slate-800 dark:text-slate-200' : 'text-slate-500 dark:text-slate-400' }} {{ !$loop->last ? 'shrink-0' : 'truncate' }}">
|
||||
@if(!$link['active'] && $link['url'] !== '#')
|
||||
<a class="hover:text-cyan-600 transition-colors" href="{{ $link['url'] }}">
|
||||
{{ $link['label'] }}
|
||||
</a>
|
||||
@else
|
||||
{{ $link['label'] }}
|
||||
@endif
|
||||
|
||||
@if(!$loop->last)
|
||||
<svg class="flex-shrink-0 mx-3 overflow-visible h-2.5 w-2.5 text-slate-400 dark:text-slate-600" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 1L10.6869 7.16086C10.8637 7.35239 10.8637 7.64761 10.6869 7.83914L5 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -127,9 +127,12 @@
|
||||
</button>
|
||||
|
||||
<!-- Profile Dropdown -->
|
||||
<div class="relative inline-flex" x-data="{ open: false }">
|
||||
<div class="relative inline-flex" x-data="{
|
||||
open: false,
|
||||
avatarUrl: '{{ Auth::user()->avatar_url }}'
|
||||
}" @avatar-updated.window="avatarUrl = $event.detail.url">
|
||||
<button type="button" @click="open = !open" @click.away="open = false" class="inline-flex flex-shrink-0 justify-center items-center gap-2 h-[2.375rem] w-[2.375rem] rounded-full font-medium bg-white text-gray-700 align-middle hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-white transition-all text-xs dark:bg-gray-800 dark:hover:bg-slate-800 dark:text-gray-400 dark:hover:text-white dark:focus:ring-gray-700 dark:focus:ring-offset-gray-800">
|
||||
<img class="inline-block h-[2.375rem] w-[2.375rem] rounded-full ring-2 ring-white dark:ring-gray-800 object-cover" src="{{ Auth::user()->avatar_url }}" alt="{{ Auth::user()->name }}">
|
||||
<img class="inline-block h-[2.375rem] w-[2.375rem] rounded-full ring-2 ring-white dark:ring-gray-800 object-cover" :src="avatarUrl" alt="{{ Auth::user()->name }}">
|
||||
</button>
|
||||
|
||||
<div x-show="open"
|
||||
@@ -182,17 +185,7 @@
|
||||
<!-- End Navigation Toggle -->
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<ol class="ms-3 flex items-center whitespace-nowrap" aria-label="Breadcrumb">
|
||||
<li class="flex items-center text-sm text-gray-800 dark:text-gray-400">
|
||||
Star Cloud
|
||||
<svg class="flex-shrink-0 mx-3 overflow-visible h-2.5 w-2.5 text-gray-400 dark:text-gray-600" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 1L10.6869 7.16086C10.8637 7.35239 10.8637 7.64761 10.6869 7.83914L5 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</li>
|
||||
<li class="text-sm font-semibold text-gray-800 truncate dark:text-gray-200" aria-current="page">
|
||||
儀表板
|
||||
</li>
|
||||
</ol>
|
||||
<x-breadcrumbs class="ms-3" />
|
||||
<!-- End Breadcrumb -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,7 +221,8 @@
|
||||
<!-- End Sidebar -->
|
||||
|
||||
<!-- Content -->
|
||||
<div class="w-full pt-10 px-4 sm:px-6 md:px-8 lg:pl-72">
|
||||
<div class="w-full pt-6 lg:pt-10 px-4 sm:px-6 md:px-8 lg:pl-72">
|
||||
<x-breadcrumbs class="mb-4 hidden lg:flex" />
|
||||
<main class="animate-fade-up">
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
@@ -1,32 +1,163 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-5xl mx-auto space-y-8 animate-luxury-in">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-extrabold text-slate-800 dark:text-white font-display tracking-tight">{{ __('Account Settings') }}</h2>
|
||||
<p class="text-sm font-bold text-slate-400 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Manage your profile information, security settings, and login history') }}</p>
|
||||
<div x-data="{
|
||||
photoPreview: null,
|
||||
isUploading: false,
|
||||
uploadAvatar(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Size Check (1MB = 1024 * 1024 bytes)
|
||||
if (file.size > 1024 * 1024) {
|
||||
alert('{{ __('The image is too large. Please upload an image smaller than 1MB.') }}');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isUploading = true;
|
||||
|
||||
// Show local preview immediately
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.photoPreview = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('_token', '{{ csrf_token() }}');
|
||||
|
||||
fetch('{{ route('profile.avatar') }}', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
this.photoPreview = data.avatar_url;
|
||||
// Dispatch global event for other components (like header)
|
||||
window.dispatchEvent(new CustomEvent('avatar-updated', {
|
||||
detail: { url: data.avatar_url }
|
||||
}));
|
||||
} else {
|
||||
alert(data.message || 'Upload failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred during upload');
|
||||
})
|
||||
.finally(() => {
|
||||
this.isUploading = false;
|
||||
});
|
||||
}
|
||||
}"
|
||||
class="space-y-8 animate-luxury-in text-slate-800 dark:text-white">
|
||||
<!-- Luxury Profile Banner -->
|
||||
<div class="luxury-card rounded-[2.5rem] p-1 border-luxury-accent/10 relative overflow-hidden group/banner">
|
||||
|
||||
<div class="relative p-8 md:p-12 flex flex-col lg:flex-row items-center justify-between gap-12 z-10">
|
||||
<!-- Left: Profile Core Info -->
|
||||
<div class="flex flex-col md:flex-row items-center gap-8 flex-1">
|
||||
<!-- Profile Avatar Area -->
|
||||
<div class="relative cursor-pointer group/avatar"
|
||||
@click="document.querySelector('#profile-update-form input[name=avatar]').click()">
|
||||
<div class="size-32 md:size-40 rounded-full overflow-hidden ring-4 ring-white dark:ring-slate-800 shadow-2xl transition-transform duration-500 group-hover/avatar:scale-105 relative">
|
||||
<template x-if="!photoPreview">
|
||||
<img src="{{ $user->avatar_url }}" class="size-full object-cover" alt="{{ $user->name }}">
|
||||
</template>
|
||||
<template x-if="photoPreview">
|
||||
<img :src="photoPreview" class="size-full object-cover">
|
||||
</template>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div x-show="isUploading"
|
||||
class="absolute inset-0 bg-black/60 flex items-center justify-center z-10"
|
||||
x-transition:enter="transition opacity-0"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100">
|
||||
<svg class="animate-spin size-8 text-cyan-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover/avatar:opacity-100 transition-opacity">
|
||||
<svg class="size-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
</div>
|
||||
<div class="absolute -bottom-2 -right-2 size-10 rounded-xl bg-cyan-500 flex items-center justify-center text-white shadow-lg shadow-cyan-500/40 border-2 border-white dark:border-slate-800">
|
||||
<svg x-show="!isUploading" class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/></svg>
|
||||
<svg x-show="isUploading" class="animate-spin size-5" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center md:text-left">
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-x-4 mb-2">
|
||||
<h2 class="text-4xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ $user->name }}</h2>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-[10px] font-black bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border border-cyan-500/20 uppercase tracking-widest self-center md:self-auto mt-2 md:mt-0">
|
||||
{{ __('Administrator') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-slate-400 font-bold tracking-wide flex items-center justify-center md:justify-start gap-x-2">
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2-2v10a2 2 0 002 2z"/></svg>
|
||||
{{ $user->email }}
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap justify-center md:justify-start gap-4">
|
||||
<div class="px-5 py-2.5 rounded-2xl bg-white/50 dark:bg-slate-900/50 border border-white dark:border-slate-800 backdrop-blur-sm transition-all hover:bg-white dark:hover:bg-slate-900 text-center">
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{ __('Joined') }}</p>
|
||||
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $user->created_at->format('Y/m/d') }}</p>
|
||||
</div>
|
||||
<div class="px-5 py-2.5 rounded-2xl bg-white/50 dark:bg-slate-900/50 border border-white dark:border-slate-800 backdrop-blur-sm transition-all hover:bg-white dark:hover:bg-slate-900 text-center">
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{{ __('Recent Login') }}</p>
|
||||
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $user->loginLogs->first() ? $user->loginLogs->first()->login_at->diffForHumans() : 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Stats Summary -->
|
||||
<div class="flex items-center gap-12 pr-4 lg:border-l lg:border-slate-200/50 dark:lg:border-slate-700/50 lg:pl-12">
|
||||
<div class="text-center group/stat">
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2 group-hover/stat:text-cyan-500 transition-colors">{{ __('Total Logins') }}</p>
|
||||
<div class="relative inline-block">
|
||||
<span class="text-4xl font-black text-slate-800 dark:text-white">{{ $user->loginLogs()->count() }}</span>
|
||||
<div class="absolute -bottom-1 left-0 w-full h-1 bg-cyan-500/20 rounded-full scale-x-0 group-hover/stat:scale-x-100 transition-transform origin-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-12 w-px bg-slate-100 dark:bg-slate-800 hidden sm:block"></div>
|
||||
<div class="text-center group/stat">
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2 group-hover/stat:text-cyan-500 transition-colors">{{ __('Account Status') }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="relative flex size-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full size-3 bg-emerald-500"></span>
|
||||
</span>
|
||||
<span class="text-2xl font-black text-slate-800 dark:text-white tracking-tight uppercase">{{ __('Active') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Left Side: Basic Info & Security -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<div class="luxury-card rounded-2xl p-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||
<!-- Left Side: Profile & Password -->
|
||||
<div class="space-y-8">
|
||||
<div class="luxury-card rounded-[2rem] p-8 shadow-xl shadow-slate-200/50 dark:shadow-none">
|
||||
@include('profile.partials.update-profile-information-form')
|
||||
</div>
|
||||
|
||||
<div class="luxury-card rounded-2xl p-8">
|
||||
<div class="luxury-card rounded-[2rem] p-8 shadow-xl shadow-slate-200/50 dark:shadow-none">
|
||||
@include('profile.partials.update-password-form')
|
||||
</div>
|
||||
|
||||
<div class="luxury-card rounded-2xl p-8 border-rose-500/20 dark:border-rose-500/10">
|
||||
@include('profile.partials.delete-user-form')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Login History -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="luxury-card rounded-2xl p-6 sticky top-24">
|
||||
<!-- Right Side: Login History (Wider) -->
|
||||
<div class="space-y-8">
|
||||
<div class="luxury-card rounded-[2rem] p-8 min-h-[600px] shadow-xl shadow-slate-200/50 dark:shadow-none">
|
||||
@include('profile.partials.login-history')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-xl font-black text-rose-600 dark:text-rose-500 tracking-tight">
|
||||
{{ __('Danger Zone: Delete Account') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm font-bold text-slate-400 dark:text-slate-400 uppercase tracking-widest">
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<button
|
||||
x-data=""
|
||||
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
|
||||
class="btn-luxury-rose px-8"
|
||||
>
|
||||
<span>{{ __('Delete Account') }}</span>
|
||||
</button>
|
||||
|
||||
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
|
||||
<form method="post" action="{{ route('profile.destroy') }}" class="p-8">
|
||||
@csrf
|
||||
@method('delete')
|
||||
|
||||
<h2 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight">
|
||||
{{ __('Are you sure you want to delete your account?') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-3 text-sm font-bold text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-8">
|
||||
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
|
||||
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="py-3 px-4 block w-full border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900 rounded-2xl text-sm font-bold text-slate-700 dark:text-slate-200 focus:ring-4 focus:ring-rose-500/10 focus:border-rose-500 transition-all outline-none"
|
||||
placeholder="{{ __('Enter your password to confirm') }}"
|
||||
/>
|
||||
|
||||
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end gap-x-3">
|
||||
<button type="button" x-on:click="$dispatch('close')" class="py-3 px-6 inline-flex items-center gap-x-2 text-sm font-black rounded-2xl border border-slate-200 bg-white text-slate-600 shadow-sm hover:bg-slate-50 focus:outline-none focus:bg-slate-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:focus:bg-slate-800 transition-all">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
|
||||
<button type="submit" class="btn-luxury-rose px-8">
|
||||
<span>{{ __('Permanently Delete Account') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
||||
</section>
|
||||
@@ -1,46 +1,62 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center gap-x-3 mb-6">
|
||||
<div class="size-10 rounded-xl bg-cyan-500/10 flex items-center justify-center text-cyan-600 dark:text-cyan-400">
|
||||
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="size-12 rounded-2xl bg-cyan-500/10 flex items-center justify-center text-cyan-600 dark:text-cyan-400 border border-cyan-500/20 shadow-lg shadow-cyan-500/5">
|
||||
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Login History') }}</h3>
|
||||
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-[0.2em]">{{ __('Your recent account activity') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-black text-slate-800 dark:text-white tracking-tight">{{ __('Login History') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="flow-root">
|
||||
<ul role="list" class="-mb-8">
|
||||
@forelse($user->loginLogs as $log)
|
||||
<li>
|
||||
<div class="relative pb-8">
|
||||
@if(!$loop->last)
|
||||
<span class="absolute left-4 top-4 -ml-px h-full w-0.5 bg-slate-100 dark:bg-slate-800" aria-hidden="true"></span>
|
||||
@endif
|
||||
<div class="relative flex space-x-3 mt-1">
|
||||
<div>
|
||||
<span class="h-8 w-8 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center ring-8 ring-white dark:ring-slate-900">
|
||||
<svg class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="relative">
|
||||
<!-- Vertical Line -->
|
||||
<div class="absolute left-6 top-2 bottom-2 w-px bg-gradient-to-b from-slate-200 via-slate-200 to-transparent dark:from-slate-800 dark:via-slate-800 dark:to-transparent"></div>
|
||||
|
||||
<div class="space-y-6">
|
||||
@forelse($user->loginLogs()->latest()->take(10)->get() as $log)
|
||||
<div class="relative pl-14 group">
|
||||
<!-- Dot -->
|
||||
<div class="absolute left-[21px] top-3 size-2.5 rounded-full border-2 border-white dark:border-slate-900 bg-slate-300 dark:bg-slate-700 group-hover:bg-cyan-500 group-hover:scale-125 transition-all duration-300 z-10"></div>
|
||||
|
||||
<div class="luxury-card p-5 rounded-2xl border border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 hover:bg-white dark:hover:bg-slate-800/80 transition-all duration-300">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-x-3 mb-2">
|
||||
<span class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono tracking-tight">{{ $log->ip_address }}</span>
|
||||
<span class="size-1 rounded-full bg-slate-300 dark:bg-slate-600"></span>
|
||||
<span class="text-[10px] font-black text-cyan-500 dark:text-cyan-400 uppercase tracking-widest bg-cyan-500/5 px-2 py-0.5 rounded-md border border-cyan-500/10">
|
||||
{{ __('Success') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs font-medium text-slate-400 dark:text-slate-500 break-all leading-relaxed" title="{{ $log->user_agent }}">
|
||||
<span class="font-bold text-slate-500 dark:text-slate-400 mr-1 italic">{{ __('Device:') }}</span>
|
||||
{{ $log->user_agent }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
|
||||
<div>
|
||||
<p class="text-[11px] font-black text-slate-700 dark:text-slate-300 uppercase tracking-widest">{{ $log->ip_address }}</p>
|
||||
<p class="mt-1 text-xs text-slate-400 truncate max-w-[120px]" title="{{ $log->user_agent }}">
|
||||
{{ Str::limit($log->user_agent, 20) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="whitespace-nowrap text-right text-[10px] font-bold text-slate-400">
|
||||
<time datetime="{{ $log->login_at }}">{{ $log->login_at->diffForHumans() }}</time>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-1">
|
||||
{{ $log->login_at->format('Y/m/d') }}
|
||||
</p>
|
||||
<p class="text-xs font-bold text-slate-400 dark:text-slate-500">
|
||||
{{ $log->login_at->diffForHumans() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
@empty
|
||||
<li class="py-4 text-center text-xs text-slate-400">{{ __('No login history yet') }}</li>
|
||||
<div class="luxury-card p-12 text-center rounded-[2rem] border-dashed border-2 border-slate-100 dark:border-slate-800">
|
||||
<div class="size-16 rounded-full bg-slate-50 dark:bg-slate-900 flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="size-8 text-slate-300 dark:text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-slate-400">{{ __('No login history yet') }}</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,53 +13,13 @@
|
||||
@csrf
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ route('profile.update') }}" class="mt-8 space-y-6" enctype="multipart/form-data">
|
||||
<form id="profile-update-form" method="post" action="{{ route('profile.update') }}" class="mt-8 space-y-6" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('patch')
|
||||
|
||||
<!-- Avatar Upload -->
|
||||
<div x-data="{ photoName: null, photoPreview: null }" class="flex flex-col items-center gap-y-4 mb-8 bg-slate-50/50 dark:bg-slate-900/50 p-6 rounded-3xl border border-slate-100 dark:border-slate-800">
|
||||
<div class="relative group">
|
||||
<!-- Current Avatar / Preview -->
|
||||
<div class="size-24 rounded-full overflow-hidden ring-4 ring-white dark:ring-gray-800 shadow-xl">
|
||||
<template x-if="! photoPreview">
|
||||
<img src="{{ $user->avatar_url }}" class="size-full object-cover" alt="{{ $user->name }}">
|
||||
</template>
|
||||
<template x-if="photoPreview">
|
||||
<img :src="photoPreview" class="size-full object-cover">
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Upload Overlay -->
|
||||
<label for="avatar" class="absolute inset-0 flex items-center justify-center bg-black/40 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer">
|
||||
<svg class="size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<input type="file" id="avatar" name="avatar" class="hidden"
|
||||
accept="image/*"
|
||||
@change="
|
||||
photoName = $event.target.files[0].name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
photoPreview = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL($event.target.files[0]);
|
||||
">
|
||||
<button type="button" @click="$refs.avatarInput.click()" class="text-xs font-black text-cyan-600 dark:text-cyan-500 uppercase tracking-widest hover:text-cyan-700 transition-colors">
|
||||
{{ __('Change Avatar') }}
|
||||
</button>
|
||||
<p class="mt-1 text-[10px] text-slate-400 font-bold uppercase tracking-wider">{{ __('JPG, PNG or GIF. Max 2MB.') }}</p>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('avatar')" />
|
||||
</div>
|
||||
|
||||
<!-- Hidden ref for clicking -->
|
||||
<input type="file" x-ref="avatarInput" class="hidden" accept="image/*" @change="/* same as above but easier to click */">
|
||||
</div>
|
||||
<!-- Hidden Avatar Input -->
|
||||
<input type="file" name="avatar" class="hidden" accept="image/*"
|
||||
@change="uploadAvatar($event)">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
|
||||
@@ -171,7 +171,7 @@ Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||
Route::post("/profile/avatar", [ProfileController::class, "updateAvatar"])->name("profile.avatar");
|
||||
});
|
||||
|
||||
require __DIR__.'/auth.php';
|
||||
|
||||
Reference in New Issue
Block a user