feat: add resources and view components
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Figtree", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--color-primary: #1e1e1e;
|
||||
}
|
||||
|
||||
@plugin "@tailwindcss/forms";
|
||||
@@ -0,0 +1,10 @@
|
||||
import "./bootstrap";
|
||||
import Alpine from "alpinejs";
|
||||
|
||||
// Prevent double initialization if Alpine is already provided by legacy bundles
|
||||
if (!window.Alpine) {
|
||||
window.Alpine = Alpine;
|
||||
Alpine.start();
|
||||
} else {
|
||||
console.warn("Alpine.js already initialized by another script. Skipping Vite initialization to avoid conflicts.");
|
||||
}
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
|
||||
window.Pusher = Pusher;
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Italo Israel Baeza Cabrera
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use Webpass instead
|
||||
* @see https://github.com/Laragear/webpass
|
||||
*/
|
||||
class WebAuthn {
|
||||
/**
|
||||
* Routes for WebAuthn assertion (login) and attestation (register).
|
||||
*
|
||||
* @type {{registerOptions: string, register: string, loginOptions: string, login: string, }}
|
||||
*/
|
||||
#routes = {
|
||||
registerOptions: "webauthn/register/options",
|
||||
register: "webauthn/register",
|
||||
loginOptions: "webauthn/login/options",
|
||||
login: "webauthn/login",
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to use in ALL requests done.
|
||||
*
|
||||
* @type {{Accept: string, "Content-Type": string, "X-Requested-With": string}}
|
||||
*/
|
||||
#headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
|
||||
/**
|
||||
* If set to true, the credentials option will be set to 'include' on all fetch calls,
|
||||
* or else it will use the default 'same-origin'. Use this if the backend is not the
|
||||
* same origin as the client or the XSRF protection will break without the session.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#includeCredentials = false
|
||||
|
||||
/**
|
||||
* Create a new WebAuthn instance.
|
||||
*
|
||||
* @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}}
|
||||
* @param headers {{string}}
|
||||
* @param includeCredentials {boolean}
|
||||
* @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars)
|
||||
*/
|
||||
constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) {
|
||||
console.warn('This WebAuthn Helper is deprecated and will be removed in the future. Consider migrating to @laragear/webpass')
|
||||
|
||||
Object.assign(this.#routes, routes);
|
||||
Object.assign(this.#headers, headers);
|
||||
|
||||
this.#includeCredentials = includeCredentials;
|
||||
|
||||
let xsrfToken;
|
||||
let csrfToken;
|
||||
|
||||
if (xcsrfToken === null) {
|
||||
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
||||
xsrfToken = WebAuthn.#XsrfToken;
|
||||
csrfToken = WebAuthn.#firstInputWithCsrfToken;
|
||||
} else{
|
||||
// Check if it is a CSRF or XSRF token
|
||||
if (xcsrfToken.length === 40) {
|
||||
csrfToken = xcsrfToken;
|
||||
} else if (xcsrfToken.length === 224) {
|
||||
xsrfToken = xcsrfToken;
|
||||
} else {
|
||||
throw new TypeError('CSRF token or XSRF token provided does not match requirements. Must be 40 or 224 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
if (xsrfToken !== null) {
|
||||
this.#headers["X-XSRF-TOKEN"] ??= xsrfToken;
|
||||
} else if (csrfToken !== null) {
|
||||
this.#headers["X-CSRF-TOKEN"] ??= csrfToken;
|
||||
} else {
|
||||
// We didn't find it, and since is required, we will bail out.
|
||||
throw new TypeError('Ensure a CSRF/XSRF token is manually set, or provided in a cookie "XSRF-TOKEN" or or there is meta tag named "csrf-token".');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSRF token if it exists as a form input tag.
|
||||
*
|
||||
* @returns string
|
||||
* @throws TypeError
|
||||
*/
|
||||
static get #firstInputWithCsrfToken() {
|
||||
// First, try finding an CSRF Token in the head.
|
||||
let token = Array.from(document.head.getElementsByTagName("meta"))
|
||||
.find(element => element.name === "csrf-token");
|
||||
|
||||
if (token) {
|
||||
return token.content;
|
||||
}
|
||||
|
||||
// Then, try to find a hidden input containing the CSRF token.
|
||||
token = Array.from(document.getElementsByTagName('input'))
|
||||
.find(input => input.name === "_token" && input.type === "hidden")
|
||||
|
||||
if (token) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the XSRF token if it exists in a cookie.
|
||||
*
|
||||
* Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2
|
||||
*
|
||||
* @returns {?string}
|
||||
*/
|
||||
static get #XsrfToken() {
|
||||
const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row));
|
||||
// We must remove all '%3D' from the end of the string.
|
||||
// Background:
|
||||
// The actual binary value of the CSFR value is encoded in Base64.
|
||||
// If the length of original, binary value is not a multiple of 3 bytes,
|
||||
// the encoding gets padded with `=` on the right; i.e. there might be
|
||||
// zero, one or two `=` at the end of the encoded value.
|
||||
// If the value is sent from the server to the client as part of a cookie,
|
||||
// the `=` character is URL-encoded as `%3D`, because `=` is already used
|
||||
// to separate a cookie key from its value.
|
||||
// When we send back the value to the server as part of an AJAX request,
|
||||
// Laravel expects an unpadded value.
|
||||
// Hence, we must remove the `%3D`.
|
||||
return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a fetch promise to resolve later.
|
||||
*
|
||||
* @param data {Object}
|
||||
* @param route {string}
|
||||
* @param headers {{string}}
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
#fetch(data, route, headers = {}) {
|
||||
const url = new URL(route, window.location.origin).href;
|
||||
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
credentials: this.#includeCredentials ? "include" : "same-origin",
|
||||
redirect: "error",
|
||||
headers: {...this.#headers, ...headers},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
static #base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return atob(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param useAtob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static #uint8Array(input, useAtob = false) {
|
||||
return Uint8Array.from(
|
||||
useAtob ? atob(input) : WebAuthn.#base64UrlDecode(input), c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
static #arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
#parseIncomingServerOptions(publicKey) {
|
||||
console.debug(publicKey);
|
||||
|
||||
publicKey.challenge = WebAuthn.#uint8Array(publicKey.challenge);
|
||||
|
||||
if ('user' in publicKey) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: WebAuthn.#uint8Array(publicKey.user.id)
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
"excludeCredentials",
|
||||
"allowCredentials"
|
||||
]
|
||||
.filter(key => key in publicKey)
|
||||
.forEach(key => {
|
||||
publicKey[key] = publicKey[key].map(data => {
|
||||
return {...data, id: WebAuthn.#uint8Array(data.id)};
|
||||
});
|
||||
});
|
||||
|
||||
console.log(publicKey);
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
#parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: WebAuthn.#arrayToBase64String(credentials.rawId),
|
||||
authenticatorAttachment: credentials.authenticatorAttachment,
|
||||
clientExtensionResults: credentials.getClientExtensionResults(),
|
||||
response: {},
|
||||
}
|
||||
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter(key => key in credentials.response)
|
||||
.forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key]));
|
||||
|
||||
return parseCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the Server.
|
||||
*
|
||||
* Throws the entire response if is not OK (HTTP 2XX).
|
||||
*
|
||||
* @param response {Response}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
* @throws Response
|
||||
*/
|
||||
static #handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Here we will do a small trick. Since most of the responses from the server
|
||||
// are JSON, we will automatically parse the JSON body from the response. If
|
||||
// it's not JSON, we will push the body verbatim and let the dev handle it.
|
||||
return new Promise((resolve) => {
|
||||
response
|
||||
.json()
|
||||
.then((json) => resolve(json))
|
||||
.catch(() => resolve(response.body));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user credentials from the browser/device.
|
||||
*
|
||||
* You can add request input if you are planning to register a user with WebAuthn from scratch.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async register(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.registerOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.create({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
Object.assign(publicKeyCredential, request);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in a user with his credentials.
|
||||
*
|
||||
* If no credentials are given, the app may return a blank assertion for userless login.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async login(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.loginOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.get({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
|
||||
console.log(publicKeyCredential);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.login, response).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser supports WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static supportsWebAuthn() {
|
||||
return typeof PublicKeyCredential != "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser doesn't support WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static doesntSupportWebAuthn() {
|
||||
return !this.supportsWebAuthn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<x-guest-layout>
|
||||
@php
|
||||
$logo = get_setting('app_logo');
|
||||
@endphp
|
||||
|
||||
{{-- Logo & Intro --}}
|
||||
<div class="text-center text-primary mb-5">
|
||||
<img src="{{ asset('assets/img/logo.png') }}" alt="logo" class="maxwidth-200 mx-auto"><br>
|
||||
<p class="opacity-75 fs-6">
|
||||
This is a secure area. Please confirm your password to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Form Konfirmasi Password --}}
|
||||
<form method="POST" action="{{ route('password.confirm') }}">
|
||||
@csrf
|
||||
|
||||
{{-- Input Password --}}
|
||||
<div class="input-group mb-4">
|
||||
<div class="form-floating flex-grow-1">
|
||||
<input type="password" id="password" name="password"
|
||||
class="form-control border-end-0 @error('password') is-invalid @enderror" placeholder="Password" required
|
||||
autocomplete="current-password">
|
||||
<label for="password">Password</label>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Pesan Error Validasi --}}
|
||||
@error('password')
|
||||
<div class="text-danger small mt-1 mb-4">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Tombol Submit --}}
|
||||
<button type="submit" class="btn btn-lg btn-primary theme-black w-100">
|
||||
Confirm
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="text-center text-primary mb-5">
|
||||
{{-- Footer --}}
|
||||
<small class="opacity-50 d-block mt-4"> {{ $footer_text }} </small>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,54 @@
|
||||
<x-guest-layout>
|
||||
@php
|
||||
$logo = get_setting('app_logo');
|
||||
@endphp
|
||||
|
||||
{{-- Logo & Intro --}}
|
||||
<div class="text-center text-primary mb-5">
|
||||
<img src="{{ asset('assets/img/logo.png') }}" alt="logo" class="maxwidth-200 mx-auto"><br>
|
||||
<i class="fs-15 opacity-75 mb-0">
|
||||
{{ __('Forgot your password? Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||
</i>
|
||||
</div>
|
||||
|
||||
{{-- Status Notification (Reset link successfully sent) --}}
|
||||
@if (session('status'))
|
||||
<div class="alert alert-success">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Form Request Reset Password --}}
|
||||
<form method="POST" action="{{ route('password.email') }}">
|
||||
@csrf
|
||||
|
||||
{{-- Input Email --}}
|
||||
<div class="form-floating mb-3">
|
||||
<input type="email" id="email" name="email" class="form-control @error('email') is-invalid @enderror"
|
||||
placeholder="Enter your email" value="{{ old('email') }}" autocomplete="off" required autofocus>
|
||||
|
||||
<label for="email">Email Address</label>
|
||||
|
||||
{{-- Error Validasi --}}
|
||||
@error('email')
|
||||
<div class="text-danger small mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Button Kirim Link Reset Password --}}
|
||||
<button type="submit" class="btn btn-lg btn-primary theme-black w-100">
|
||||
Email Password Reset Link
|
||||
</button>
|
||||
|
||||
{{-- Back to Login --}}
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ route('login') }}" class="text-primary small">
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-primary mb-5">
|
||||
{{-- Footer --}}
|
||||
<small class="opacity-50 d-block mt-4"> {{ $footer_text }} </small>
|
||||
</div>
|
||||
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,183 @@
|
||||
<x-guest-layout>
|
||||
@php
|
||||
$logo = get_setting('app_logo');
|
||||
@endphp
|
||||
|
||||
{{-- Logo & Intro --}}
|
||||
<div class="text-center text-primary mb-5">
|
||||
<img src="{{ asset('assets/img/logo.png') }}" alt="logo" class="maxwidth-200 mx-auto"><br>
|
||||
<i class="fs-15 opacity-75 mb-0">
|
||||
{{ $app_tagline1 ?? '' }}<br>
|
||||
{{ $app_tagline ?? '' }}
|
||||
</i>
|
||||
</div>
|
||||
|
||||
{{-- Alert Session Status (Login feedback) --}}
|
||||
@if (session('status'))
|
||||
<div class="alert alert-success">{{ session('status') }}</div>
|
||||
@endif
|
||||
@if($errors->has('email'))
|
||||
<div class="alert alert-danger mt-2">
|
||||
{{ $errors->first('email') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Login Form --}}
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@csrf
|
||||
|
||||
{{-- Email Input --}}
|
||||
<div class="form-floating mb-3">
|
||||
<input type="email" autocomplete="off" class="form-control @error('email') is-invalid @enderror" id="email"
|
||||
name="email" placeholder="Enter email" required value="{{ old('email') }}">
|
||||
<label for="email">Email Address</label>
|
||||
</div>
|
||||
|
||||
{{-- Password Input --}}
|
||||
<div class="input-group mb-3">
|
||||
<div class="form-floating flex-grow-1">
|
||||
<input type="password" autocomplete="new-password"
|
||||
class="form-control border-end-0 @error('password') is-invalid @enderror" id="password"
|
||||
name="password" placeholder="Enter your password" required>
|
||||
<label for="password">Password</label>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button"
|
||||
style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Options: Remember + Forgot Password --}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<label class="text-primary">
|
||||
<input type="checkbox" name="remember"> Remember me
|
||||
</label>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<a href="{{ route('password.request') }}" class="text-primary small">
|
||||
Forgot Password?
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
{{-- Captcha --}}
|
||||
@if(get_setting('captcha_enabled', false))
|
||||
<div class="mb-3 d-flex justify-content-center">
|
||||
{!! NoCaptcha::display() !!}
|
||||
</div>
|
||||
@if ($errors->has('g-recaptcha-response'))
|
||||
<div class="alert alert-danger py-2 small mb-3">
|
||||
{{ $errors->first('g-recaptcha-response') }}
|
||||
</div>
|
||||
@endif
|
||||
{!! NoCaptcha::renderJs() !!}
|
||||
@endif
|
||||
|
||||
{{-- Submit --}}
|
||||
<button type="submit" class="btn btn-lg btn-primary theme-black w-100 mb-2">
|
||||
Login
|
||||
</button>
|
||||
|
||||
@if(get_setting('webauthn_enabled', false))
|
||||
<button type="button" id="passkey-login" class="btn btn-lg btn-info w-100 mb-2 text-white">
|
||||
<i class="bi bi-fingerprint me-2"></i> {{ __('Login with Passkey') }}
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Social Logins --}}
|
||||
<div class="row g-2 mt-2">
|
||||
@if(get_setting('feature_google_oauth', false))
|
||||
<div class="col-12">
|
||||
<a href="{{ route('auth.social', 'google') }}" class="btn btn-lg btn-outline-dark w-100">
|
||||
<img src="{{ asset('assets/img/google.png') }}" alt="google" class="maxwidth-20 mx-auto me-2">
|
||||
{{ __('Login with Google') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(get_setting('feature_facebook_oauth', false))
|
||||
<div class="col-12">
|
||||
<a href="{{ route('auth.social', 'facebook') }}" class="btn btn-lg btn-outline-dark w-100">
|
||||
<img src="{{ asset('assets/img/facebook.png') }}" alt="facebook" class="maxwidth-20 mx-auto me-2">
|
||||
{{ __('Login with Facebook') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(get_setting('feature_github_oauth', false))
|
||||
<div class="col-12">
|
||||
<a href="{{ route('auth.social', 'github') }}" class="btn btn-lg btn-outline-dark w-100">
|
||||
<img src="{{ asset('assets/img/github.png') }}" alt="github" class="maxwidth-20 mx-auto me-2">
|
||||
{{ __('Login with GitHub') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if(get_setting('webauthn_enabled', false))
|
||||
<script src="{{ asset('vendor/webauthn/webauthn.js') }}"></script>
|
||||
<script>
|
||||
document.getElementById('passkey-login').addEventListener('click', async () => {
|
||||
const passkeyBtn = document.getElementById('passkey-login');
|
||||
|
||||
// Check if class is available
|
||||
if (typeof WebAuthn === 'undefined') {
|
||||
console.error('WebAuthn library not loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (WebAuthn.supportsWebAuthn()) {
|
||||
try {
|
||||
passkeyBtn.disabled = true;
|
||||
passkeyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> {{ __("Authenticating...") }}';
|
||||
|
||||
const webauthn = new WebAuthn();
|
||||
const response = await webauthn.login();
|
||||
|
||||
if (response) {
|
||||
window.location.href = "{{ route('dashboard') }}";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn Error:', error);
|
||||
|
||||
let errorMsg = 'Authentication failed. Please use your password.';
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
errorMsg = 'Passkeys require a secure context (HTTPS). \n\n' +
|
||||
'Developer Tip: If you are on a local domain (like .test), you can bypass this in Chrome/Edge by going to: \n' +
|
||||
'chrome://flags/#unsafely-treat-insecure-origin-as-secure \n' +
|
||||
'and adding your domain to the list.';
|
||||
} else if (error.name === 'NotAllowedError') {
|
||||
errorMsg = 'Authentication was cancelled or timed out.';
|
||||
} else {
|
||||
errorMsg = 'Login failed. Ensure you have registered a passkey on this device.';
|
||||
}
|
||||
|
||||
StandardSwal.fire({
|
||||
icon: 'error',
|
||||
title: 'Authentication Failed!',
|
||||
text: errorMsg || 'Unable to log in with Passkey. Please verify your device settings or use your password.',
|
||||
confirmButtonText: 'Try Again'
|
||||
});
|
||||
} finally {
|
||||
passkeyBtn.disabled = false;
|
||||
passkeyBtn.innerHTML = '<i class="bi bi-fingerprint me-2"></i> {{ __("Login with Passkey") }}';
|
||||
}
|
||||
} else {
|
||||
StandardSwal.fire({
|
||||
icon: 'info',
|
||||
title: 'Passkey Not Supported',
|
||||
text: 'Your current browser or device does not support secure Passkey authentication.',
|
||||
confirmButtonText: 'Understood'
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<div class="text-center text-primary mb-5">
|
||||
{{-- Footer --}}
|
||||
<small class="opacity-50 d-block mt-4"> {{ $footer_text }} </small>
|
||||
</div>
|
||||
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,10 @@
|
||||
<x-guest-layout>
|
||||
<div class="text-center">
|
||||
<h1 class="fw-bold fs-2">🚧 Under Maintenance</h1>
|
||||
<p class="mt-2 text-secondary">Free Trial is currently unavailable at this time.</p>
|
||||
|
||||
<a href="{{ url()->previous() }}" class="btn btn-dark mt-4 px-5">
|
||||
{{ __('Back') }}
|
||||
</a>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,220 @@
|
||||
<x-guest-layout>
|
||||
@php
|
||||
$logo = get_setting('app_logo');
|
||||
@endphp
|
||||
|
||||
{{-- Logo & Intro --}}
|
||||
<div class="text-center text-primary mb-5">
|
||||
<img src="{{ asset('assets/img/logo.png') }}" alt="logo" class="maxwidth-200 mx-auto"><br>
|
||||
<i class="fs-15 opacity-75">
|
||||
Start your journey with <b>biiproject.</b>
|
||||
</i>
|
||||
</div>
|
||||
|
||||
{{-- Session Status (feedback setelah submit) --}}
|
||||
@if (session('status'))
|
||||
<div class="alert alert-success">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Form Register User Baru --}}
|
||||
<form method="POST" action="{{ route('register') }}">
|
||||
@csrf
|
||||
|
||||
{{-- Full Name --}}
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" id="name" name="name" class="form-control @error('name') is-invalid @enderror"
|
||||
placeholder="Full Name" value="{{ old('name') }}" required autocomplete="name" autofocus>
|
||||
|
||||
<label for="name">Full Name</label>
|
||||
|
||||
@error('name')
|
||||
<div class="text-danger small mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Email --}}
|
||||
<div class="form-floating mb-3">
|
||||
<input type="email" id="email" name="email" class="form-control @error('email') is-invalid @enderror"
|
||||
placeholder="Email Address" value="{{ old('email') }}" autocomplete="off" required>
|
||||
|
||||
<label for="email">Email Address</label>
|
||||
|
||||
@error('email')
|
||||
<div class="text-danger small mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Password --}}
|
||||
<div class="input-group mb-3">
|
||||
<div class="form-floating flex-grow-1">
|
||||
<input type="password" id="password" name="password"
|
||||
class="form-control border-end-0 @error('password') is-invalid @enderror" placeholder="Password"
|
||||
autocomplete="new-password" required minlength="{{ get_setting('password_min_length', 12) }}"
|
||||
title="Minimum {{ get_setting('password_min_length', 12) }} characters">
|
||||
<label for="password">Password</label>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@error('password')
|
||||
<div class="text-danger small mt-1 mb-3">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Confirm Password --}}
|
||||
<div class="input-group mb-4">
|
||||
<div class="form-floating flex-grow-1">
|
||||
<input type="password" id="password_confirmation" name="password_confirmation"
|
||||
class="form-control border-end-0 @error('password_confirmation') is-invalid @enderror" placeholder="Confirm Password"
|
||||
autocomplete="new-password" required minlength="{{ get_setting('password_min_length', 12) }}"
|
||||
title="Must match the new password exactly">
|
||||
<label for="password_confirmation">Confirm Password</label>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@error('password_confirmation')
|
||||
<div class="text-danger small mt-1 mb-4">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Consent Checkboxes (Fintech Style) --}}
|
||||
<div class="mb-4">
|
||||
<div class="p-3 rounded-4 border bg-white-50 shadow-sm consent-container" style="background: rgba(255,255,255,0.5); transition: all 0.3s ease;">
|
||||
<div class="form-check custom-check mb-2 d-flex align-items-center">
|
||||
<input type="checkbox" name="agree_tos_pdp" class="form-check-input flex-shrink-0 mt-0" id="agree_legal" value="1" required>
|
||||
<label class="form-check-label small lh-sm ms-2" for="agree_legal" style="cursor: pointer;">
|
||||
{{ __('I have read and agree to the') }}
|
||||
<a href="javascript:void(0)" onclick="openLegalModal('tos')" class="text-primary text-decoration-none fw-bold">{{ __('Terms of Use') }}</a>
|
||||
{{ __('and') }}
|
||||
<a href="javascript:void(0)" onclick="openLegalModal('privacy')" class="text-primary text-decoration-none fw-bold">{{ __('Privacy Policy (UU PDP)') }}</a>.
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check custom-check d-flex align-items-center">
|
||||
<input type="checkbox" name="marketing_consent" class="form-check-input flex-shrink-0 mt-0" id="agree_marketing" value="1">
|
||||
<label class="form-check-label small lh-sm ms-2" for="agree_marketing" style="cursor: pointer;">
|
||||
{{ __('I consent to receive newsletters, marketing emails, and service updates.') }} <span class="text-muted">({{ __('Optional') }})</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@error('agree_tos_pdp')
|
||||
<div class="text-danger small mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Submit --}}
|
||||
<button type="submit" class="btn btn-lg btn-primary theme-black w-100">
|
||||
Register
|
||||
</button>
|
||||
|
||||
{{-- Redirect ke Login --}}
|
||||
<div class="text-center">
|
||||
<a href="{{ route('login') }}" class="text-primary small">
|
||||
Already registered? Login here
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-primary mb-5">
|
||||
{{-- Footer --}}
|
||||
<small class="opacity-50 d-block mt-4"> {{ $footer_text }} </small>
|
||||
</div>
|
||||
|
||||
@push('modals')
|
||||
{{-- Legal Viewer Modal --}}
|
||||
<div class="modal fade" id="legalModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 30px; overflow: hidden;">
|
||||
<div class="modal-header border-0 p-4 pb-0" style="padding: 2.5rem 2.5rem 0.5rem !important;">
|
||||
<h4 class="modal-title fw-bold" id="legalModalTitle" style="font-family: var(--adminuiux-title-font);">{{ __('Legal Document') }}</h4>
|
||||
<button type="button" class="btn-close me-1 mt-1" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4" style="padding: 1rem 2.5rem 2.5rem !important;">
|
||||
<div id="legalModalContent" class="legal-content-preview">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm mb-3"></div>
|
||||
<p class="small text-muted">{{ __('Loading document...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 p-4 pt-0" style="padding: 0 2.5rem 2.5rem !important;">
|
||||
<button type="button" class="btn btn-pill-standard-primary w-100 py-3" data-bs-dismiss="modal">{{ __('Close & Continue') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function openLegalModal(type) {
|
||||
const modalEl = document.getElementById('legalModal');
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
const contentDiv = document.getElementById('legalModalContent');
|
||||
const titleEl = document.getElementById('legalModalTitle');
|
||||
|
||||
// Set Titles
|
||||
titleEl.innerText = type === 'tos' ? "{{ __('Terms of Use') }}" : "{{ __('Privacy Policy') }}";
|
||||
|
||||
// Show Modal
|
||||
modal.show();
|
||||
|
||||
// Loader
|
||||
contentDiv.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm mb-3"></div>
|
||||
<p class="small text-muted">{{ __('Loading document...') }}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Load Content via AJAX
|
||||
fetch(`/legal/${type}`)
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const actualContent = doc.querySelector('.legal-content') ? doc.querySelector('.legal-content').innerHTML : html;
|
||||
contentDiv.innerHTML = actualContent;
|
||||
})
|
||||
.catch(err => {
|
||||
contentDiv.innerHTML = '<div class="alert alert-danger">{{ __('Failed to load content.') }}</div>';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.consent-container:hover {
|
||||
background: rgba(255,255,255,0.7) !important;
|
||||
border-color: var(--bs-primary) !important;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.05) !important;
|
||||
}
|
||||
.custom-check .form-check-input {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-right: 0 !important;
|
||||
border: 2px solid #dee2e6;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.custom-check .form-check-input:checked {
|
||||
background-color: #000;
|
||||
border-color: #000;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.legal-content-preview {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
color: #333;
|
||||
}
|
||||
.legal-content-preview h1, .legal-content-preview h2, .legal-content-preview h3 {
|
||||
font-family: var(--adminuiux-title-font);
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,83 @@
|
||||
<x-guest-layout>
|
||||
@php
|
||||
$logo = get_setting('app_logo');
|
||||
@endphp
|
||||
|
||||
{{-- Logo & Intro --}}
|
||||
<div class="text-center text-primary mb-5">
|
||||
<img src="{{ asset('assets/img/logo.png') }}" alt="logo" class="maxwidth-200 mx-auto"><br>
|
||||
<p class="opacity-75 fs-6">Enter your new password to reset your account.</p>
|
||||
</div>
|
||||
|
||||
{{-- Form Reset Password --}}
|
||||
<form method="POST" action="{{ route('password.store') }}">
|
||||
@csrf
|
||||
|
||||
{{-- Token Reset (dibawa dari email) --}}
|
||||
<input type="hidden" name="token" value="{{ $request->route('token') }}">
|
||||
|
||||
{{-- Email --}}
|
||||
<div class="form-floating mb-3">
|
||||
<input type="email" id="email" name="email" class="form-control @error('email') is-invalid @enderror"
|
||||
placeholder="Email Address" value="{{ old('email', $request->email) }}" required autofocus
|
||||
autocomplete="username">
|
||||
|
||||
<label for="email">Email Address</label>
|
||||
|
||||
{{-- Error Validasi --}}
|
||||
@error('email')
|
||||
<div class="text-danger small mt-1">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Password Baru --}}
|
||||
<div class="input-group mb-3">
|
||||
<div class="form-floating flex-grow-1">
|
||||
<input type="password" id="password" name="password"
|
||||
class="form-control border-end-0 @error('password') is-invalid @enderror" placeholder="New Password"
|
||||
autocomplete="new-password" required>
|
||||
<label for="password">New Password</label>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@error('password')
|
||||
<div class="text-danger small mt-1 mb-3">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Konfirmasi Password Baru --}}
|
||||
<div class="input-group mb-4">
|
||||
<div class="form-floating flex-grow-1">
|
||||
<input type="password" id="password_confirmation" name="password_confirmation"
|
||||
class="form-control border-end-0 @error('password_confirmation') is-invalid @enderror" placeholder="Confirm Password"
|
||||
autocomplete="new-password" required>
|
||||
<label for="password_confirmation">Confirm Password</label>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@error('password_confirmation')
|
||||
<div class="text-danger small mt-1 mb-4">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
{{-- Tombol Reset --}}
|
||||
<button type="submit" class="btn btn-lg btn-primary theme-black w-100">
|
||||
Reset Password
|
||||
</button>
|
||||
|
||||
{{-- Redirect ke Login --}}
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ route('login') }}" class="text-primary small">Back to Login</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-primary mb-5">
|
||||
{{-- Footer --}}
|
||||
<small class="opacity-50 d-block mt-4"> {{ $footer_text }} </small>
|
||||
</div>
|
||||
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,41 @@
|
||||
<x-guest-layout>
|
||||
<div class="text-center text-primary mb-5">
|
||||
<h3 class="fw-bold">{{ __('Two-Factor Authentication') }}</h3>
|
||||
<p class="text-muted">{{ __('Please enter the verification code sent to your email.') }}</p>
|
||||
</div>
|
||||
|
||||
@if (session('error'))
|
||||
<div class="alert alert-danger">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
@if (app()->environment('local') && session('dev_otp'))
|
||||
<div class="alert alert-info border-0 shadow-sm mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Developer Tip:</strong> Your OTP code is <code>{{ session('dev_otp') }}</code>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('2fa.verify') }}">
|
||||
@csrf
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input type="text" name="code" class="form-control" id="2fa_code" placeholder="Enter code" required autofocus maxlength="6">
|
||||
<label for="2fa_code">Verification Code</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="trust_device" id="trust_device">
|
||||
<label class="form-check-label small text-muted" for="trust_device">
|
||||
{{ __('Trust this device for') }} {{ get_setting('two_factor_trust_days', 30) }} {{ __('days') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-lg btn-primary theme-black w-100 mb-3">
|
||||
{{ __('Verify') }}
|
||||
</button>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ route('login') }}" class="text-primary small">{{ __('Back to Login') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,51 @@
|
||||
<x-guest-layout>
|
||||
@php
|
||||
$logo = get_setting('app_logo');
|
||||
@endphp
|
||||
|
||||
{{-- Logo & Intro --}}
|
||||
<div class="text-center text-primary mb-5">
|
||||
<img src="{{ asset('assets/img/logo.png') }}" alt="logo" class="maxwidth-200 mx-auto"><br>
|
||||
<p class="opacity-75 fs-6">
|
||||
Thanks for signing up!<br>
|
||||
Please verify your email to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Notification: Verification link successfully sent --}}
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<div class="alert alert-success text-center mb-4">
|
||||
A new verification link has been sent to your email address.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Informasi Panduan --}}
|
||||
<div class="text-primary mb-4 text-center">
|
||||
<small>
|
||||
We have sent you a verification link.
|
||||
Didn't receive it? You can request another one below.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
{{-- Button: Kirim ulang email verifikasi --}}
|
||||
<form method="POST" action="{{ route('verification.send') }}" class="mb-3">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-lg btn-primary theme-black w-100">
|
||||
Resend Verification Email
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{{-- Aksi Logout --}}
|
||||
<form method="POST" action="{{ route('logout') }}" class="text-center">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-link text-primary small text-decoration-underline">
|
||||
Log Out
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center text-primary mb-5">
|
||||
{{-- Footer --}}
|
||||
<small class="opacity-50 d-block mt-4"> {{ $footer_text }} </small>
|
||||
</div>
|
||||
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
|
||||
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,7 @@
|
||||
@props(['status'])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600 dark:text-green-400']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1 @@
|
||||
<a {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out']) }}>{{ $slot }}</a>
|
||||
@@ -0,0 +1,35 @@
|
||||
@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-gray-700'])
|
||||
|
||||
@php
|
||||
$alignmentClasses = match ($align) {
|
||||
'left' => 'ltr:origin-top-left rtl:origin-top-right start-0',
|
||||
'top' => 'origin-top',
|
||||
default => 'ltr:origin-top-right rtl:origin-top-left end-0',
|
||||
};
|
||||
|
||||
$width = match ($width) {
|
||||
'48' => 'w-48',
|
||||
default => $width,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false" @close.stop="open = false">
|
||||
<div @click="open = ! open">
|
||||
{{ $trigger }}
|
||||
</div>
|
||||
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute z-50 mt-2 {{ $width }} rounded-md shadow-lg {{ $alignmentClasses }}"
|
||||
style="display: none;"
|
||||
@click="open = false">
|
||||
<div class="rounded-md ring-1 ring-black ring-opacity-5 {{ $contentClasses }}">
|
||||
{{ $content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
@props(['messages'])
|
||||
|
||||
@if ($messages)
|
||||
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 dark:text-red-400 space-y-1']) }}>
|
||||
@foreach ((array) $messages as $message)
|
||||
<li>{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@@ -0,0 +1,5 @@
|
||||
@props(['value'])
|
||||
|
||||
<label {{ $attributes->merge(['class' => 'block font-medium text-sm text-gray-700 dark:text-gray-300']) }}>
|
||||
{{ $value ?? $slot }}
|
||||
</label>
|
||||
@@ -0,0 +1,78 @@
|
||||
@props([
|
||||
'name',
|
||||
'show' => false,
|
||||
'maxWidth' => '2xl'
|
||||
])
|
||||
|
||||
@php
|
||||
$maxWidth = [
|
||||
'sm' => 'sm:max-w-sm',
|
||||
'md' => 'sm:max-w-md',
|
||||
'lg' => 'sm:max-w-lg',
|
||||
'xl' => 'sm:max-w-xl',
|
||||
'2xl' => 'sm:max-w-2xl',
|
||||
][$maxWidth];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
show: @js($show),
|
||||
focusables() {
|
||||
// All focusable element types...
|
||||
let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
|
||||
return [...$el.querySelectorAll(selector)]
|
||||
// All non-disabled elements...
|
||||
.filter(el => ! el.hasAttribute('disabled'))
|
||||
},
|
||||
firstFocusable() { return this.focusables()[0] },
|
||||
lastFocusable() { return this.focusables().slice(-1)[0] },
|
||||
nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
|
||||
prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
|
||||
nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
|
||||
prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
|
||||
}"
|
||||
x-init="$watch('show', value => {
|
||||
if (value) {
|
||||
document.body.classList.add('overflow-y-hidden');
|
||||
{{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
|
||||
} else {
|
||||
document.body.classList.remove('overflow-y-hidden');
|
||||
}
|
||||
})"
|
||||
x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
|
||||
x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
|
||||
x-on:close.stop="show = false"
|
||||
x-on:keydown.escape.window="show = false"
|
||||
x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
|
||||
x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
|
||||
style="display: {{ $show ? 'block' : 'none' }};"
|
||||
>
|
||||
<div
|
||||
x-show="show"
|
||||
class="fixed inset-0 transform transition-all"
|
||||
x-on:click="show = false"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gray-500 dark:bg-gray-900 opacity-75"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="show"
|
||||
class="mb-6 bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['active'])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'submit', 'class' => 'inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,11 @@
|
||||
@props(['active'])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out'
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out';
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@@ -0,0 +1,3 @@
|
||||
<button {{ $attributes->merge(['type' => 'button', 'class' => 'inline-flex items-center px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-500 rounded-md font-semibold text-xs text-gray-700 dark:text-gray-300 uppercase tracking-widest shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-25 transition ease-in-out duration-150']) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@@ -0,0 +1,3 @@
|
||||
@props(['disabled' => false])
|
||||
|
||||
<input @disabled($disabled) {{ $attributes->merge(['class' => 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm']) }}>
|
||||
@@ -0,0 +1,37 @@
|
||||
<x-mail::message>
|
||||
# System Health Digest
|
||||
|
||||
@php
|
||||
$cpu = (int) ($stats['cpu'] ?? 0);
|
||||
$ram = (int) ($stats['ram']['percentage'] ?? 0);
|
||||
$cpuLabel = $cpu >= 85 ? '🔴 Critical' : ($cpu >= 65 ? '🟡 Elevated' : '🟢 Healthy');
|
||||
$ramLabel = $ram >= 85 ? '🔴 Critical' : ($ram >= 65 ? '🟡 Elevated' : '🟢 Healthy');
|
||||
@endphp
|
||||
|
||||
Dear System Administrator,
|
||||
|
||||
Please find enclosed the automated system performance and health report for **{{ config('app.name') }}**, generated on {{ now()->format('d M Y, H:i T') }}.
|
||||
|
||||
<x-mail::table>
|
||||
| Metric | Value | Status |
|
||||
|:-------------------------------|:----------------------------------------------------------------------------|:-----------|
|
||||
| CPU load | {{ $cpu }}% | {{ $cpuLabel }} |
|
||||
| Memory usage | {{ $stats['ram']['used'] ?? 'n/a' }} / {{ $stats['ram']['total'] ?? 'n/a' }} ({{ $ram }}%) | {{ $ramLabel }} |
|
||||
| Active sessions | {{ $stats['users'] ?? 0 }} | — |
|
||||
| Queue backlog | {{ $stats['queues']['pending'] ?? 0 }} pending | — |
|
||||
| Database size | {{ $stats['db_stats']['size'] ?? 'n/a' }} | — |
|
||||
</x-mail::table>
|
||||
|
||||
## Diagnostic Analysis Summary
|
||||
|
||||
{{ $analysis }}
|
||||
|
||||
<x-mail::button :url="config('app.url') . '/monitoring'" color="primary">
|
||||
Open Monitoring Dashboard
|
||||
</x-mail::button>
|
||||
|
||||
This is an automated administrative notification. You are receiving this communication because your account holds the necessary privileges (`view health and logs`) to monitor system diagnostics. To manage your communication preferences, please navigate to **System Settings → Notifications**.
|
||||
|
||||
Sincerely,
|
||||
**{{ config('app.name') }} IT Operations**
|
||||
</x-mail::message>
|
||||
@@ -0,0 +1,38 @@
|
||||
<x-mail::message>
|
||||
# Authentication Verification Required
|
||||
|
||||
@if($userName)
|
||||
Dear {{ $userName }},
|
||||
@else
|
||||
Greetings,
|
||||
@endif
|
||||
|
||||
A request has been made to access your **{{ config('app.name') }}** account. To proceed with the secure authentication process, please input the following One-Time Password (OTP).
|
||||
|
||||
<x-mail::panel>
|
||||
{{ $otp }}
|
||||
</x-mail::panel>
|
||||
|
||||
For security purposes, this verification code is valid for a single use and will expire in **{{ $expiresInMinutes }} minutes**. Please ensure the strict confidentiality of this credential. Authorized personnel will never solicit this code from you under any circumstances.
|
||||
|
||||
@if($ipAddress || $browser !== 'Unknown')
|
||||
---
|
||||
|
||||
**Authentication Request Origin Details**
|
||||
|
||||
@if($requestedAt)
|
||||
- Time: {{ $requestedAt }}
|
||||
@endif
|
||||
@if($ipAddress)
|
||||
- IP address: {{ $ipAddress }}
|
||||
@endif
|
||||
@if($browser !== 'Unknown' || $os !== 'Unknown')
|
||||
- Device: {{ $browser }} on {{ $os }}
|
||||
@endif
|
||||
@endif
|
||||
|
||||
If you did not explicitly authorize this access attempt, please disregard this communication. Should you persistently receive unauthorized verification requests, we strongly mandate an immediate review and update of your security credentials.
|
||||
|
||||
Sincerely,
|
||||
**{{ config('app.name') }} Security Operations**
|
||||
</x-mail::message>
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Unauthorized'))
|
||||
@section('code', '401')
|
||||
@section('message', __('Unauthorized'))
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Payment Required'))
|
||||
@section('code', '402')
|
||||
@section('message', __('Payment Required'))
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Forbidden'))
|
||||
@section('code', '403')
|
||||
@section('message', __($exception->getMessage() ?: 'Forbidden'))
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Not Found'))
|
||||
@section('code', '404')
|
||||
@section('message', __('Not Found'))
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Page Expired'))
|
||||
@section('code', '419')
|
||||
@section('message', __('Page Expired'))
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Too Many Requests'))
|
||||
@section('code', '429')
|
||||
@section('message', __('Too Many Requests'))
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Server Error'))
|
||||
@section('code', '500')
|
||||
@section('message', __('Server Error'))
|
||||
@@ -0,0 +1,260 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
|
||||
@php
|
||||
$config = app(\App\Services\SystemConfig\SystemConfigService::class);
|
||||
$settings = $config->all();
|
||||
$title = $settings['maintenance_mode_title'] ?? 'Under Maintenance';
|
||||
$message = $settings['maintenance_mode_message'] ?? 'We are currently performing scheduled maintenance. We will be back shortly!';
|
||||
$maintenance_image = $settings['maintenance_mode_image'] ?? null;
|
||||
$app_favicon = $settings['app_favicon'] ?? '';
|
||||
@endphp
|
||||
|
||||
<title>{{ $title }}</title>
|
||||
<link rel="icon" type="image/png" href="{{ asset($app_favicon) }}?v={{ time() }}">
|
||||
|
||||
{{-- Fonts & Icons --}}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<link href="{{ asset('assets/css/app.css') }}" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background-color: #fcfcfc;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #1e1e1e;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-bg-figure {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.main-bg-figure img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: invert(1) opacity(0.05);
|
||||
}
|
||||
|
||||
.maintenance-container {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Status Badge */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
padding: 8px 18px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid #eee;
|
||||
margin-bottom: 40px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #ff3b30;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
animation: pulse-red 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.4);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(255, 59, 48, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 59, 48, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #444;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Logo & Text */
|
||||
.app-logo {
|
||||
max-height: 90px;
|
||||
margin-bottom: 25px;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-weight: 800;
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 15px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.app-message {
|
||||
color: #7a8ba3;
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Black Countdown Boxes (Requested) */
|
||||
.countdown-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.countdown-box {
|
||||
background-color: #1e1e1e;
|
||||
/* Kotak Hitam */
|
||||
color: white;
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
border-radius: 20px;
|
||||
/* Sangat Bulat */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.cd-val {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cd-label {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<figure class="main-bg-figure">
|
||||
<img src="{{ asset('assets/img/background-image/bg1.png') }}" alt="Bg">
|
||||
</figure>
|
||||
|
||||
<div class="maintenance-container">
|
||||
<div class="status-badge">
|
||||
<div class="status-dot"></div>
|
||||
<div class="status-text">{{ __('Under Maintenance') }}</div>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$display_img = null;
|
||||
if (!empty($maintenance_image)) {
|
||||
$display_img = str_starts_with($maintenance_image, 'assets/') ? asset($maintenance_image) : asset('storage/' . $maintenance_image);
|
||||
} elseif (!empty($settings['app_logo'])) {
|
||||
$display_img = str_starts_with($settings['app_logo'], 'assets/') ? asset($settings['app_logo']) : asset('storage/' . $settings['app_logo']);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div class="logo-wrapper">
|
||||
@if($display_img)
|
||||
<img src="{{ $display_img }}?v={{ time() }}" class="app-logo" alt="Logo">
|
||||
@else
|
||||
<h1 class="fw-bold" style="font-size: 3.5rem; margin-bottom: 20px; font-weight: 800;">bii.</h1>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h2 class="app-title">{{ $title }}</h2>
|
||||
<p class="app-message">{{ $message }}</p>
|
||||
|
||||
@if(!empty($settings['maintenance_mode_end_at']))
|
||||
<div class="countdown-wrapper" id="timer">
|
||||
<div class="countdown-box">
|
||||
<span class="cd-val" id="days">00</span>
|
||||
<span class="cd-label">Days</span>
|
||||
</div>
|
||||
<div class="countdown-box">
|
||||
<span class="cd-val" id="hours">00</span>
|
||||
<span class="cd-label">Hours</span>
|
||||
</div>
|
||||
<div class="countdown-box">
|
||||
<span class="cd-val" id="minutes">00</span>
|
||||
<span class="cd-label">Mins</span>
|
||||
</div>
|
||||
<div class="countdown-box">
|
||||
<span class="cd-val" id="seconds">00</span>
|
||||
<span class="cd-label">Secs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const targetDate = new Date("{{ $settings['maintenance_mode_end_at'] }}").getTime();
|
||||
const updateCountdown = () => {
|
||||
const now = new Date().getTime();
|
||||
const distance = targetDate - now;
|
||||
|
||||
if (distance < 0) {
|
||||
document.getElementById("days").innerText = "00";
|
||||
document.getElementById("hours").innerText = "00";
|
||||
document.getElementById("minutes").innerText = "00";
|
||||
document.getElementById("seconds").innerText = "00";
|
||||
return;
|
||||
}
|
||||
|
||||
const d = Math.floor(distance / (1000 * 60 * 60 * 24));
|
||||
const h = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const m = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const s = Math.floor((distance % (1000 * 60)) / 1000);
|
||||
|
||||
document.getElementById("days").innerText = d.toString().padStart(2, '0');
|
||||
document.getElementById("hours").innerText = h.toString().padStart(2, '0');
|
||||
document.getElementById("minutes").innerText = m.toString().padStart(2, '0');
|
||||
document.getElementById("seconds").innerText = s.toString().padStart(2, '0');
|
||||
};
|
||||
setInterval(updateCountdown, 1000);
|
||||
updateCountdown();
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<div style="margin-top: 60px; font-size: 0.8rem; color: #bbb;">
|
||||
© {{ date('Y') }} {{ $settings['app_name'] ?? config('app.name') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>@yield('title')</title>
|
||||
|
||||
<!-- Styles -->
|
||||
<style>
|
||||
html, body {
|
||||
background-color: #fff;
|
||||
color: #636b6f;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-weight: 100;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.position-ref {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36px;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex-center position-ref full-height">
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
@yield('message')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,552 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<title>@yield('title')</title>
|
||||
|
||||
<style>
|
||||
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||
html {
|
||||
line-height: 1.15;
|
||||
-webkit-text-size-adjust: 100%
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0
|
||||
}
|
||||
|
||||
a {
|
||||
background-color: transparent
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||
line-height: 1.5
|
||||
}
|
||||
|
||||
*,
|
||||
:after,
|
||||
:before {
|
||||
box-sizing: border-box;
|
||||
border: 0 solid #e2e8f0
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: inherit
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace
|
||||
}
|
||||
|
||||
svg,
|
||||
video {
|
||||
display: block;
|
||||
vertical-align: middle
|
||||
}
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
--bg-opacity: 1;
|
||||
background-color: #fff;
|
||||
background-color: rgba(255, 255, 255, var(--bg-opacity))
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
--bg-opacity: 1;
|
||||
background-color: #f7fafc;
|
||||
background-color: rgba(247, 250, 252, var(--bg-opacity))
|
||||
}
|
||||
|
||||
.border-gray-200 {
|
||||
--border-opacity: 1;
|
||||
border-color: #edf2f7;
|
||||
border-color: rgba(237, 242, 247, var(--border-opacity))
|
||||
}
|
||||
|
||||
.border-gray-400 {
|
||||
--border-opacity: 1;
|
||||
border-color: #cbd5e0;
|
||||
border-color: rgba(203, 213, 224, var(--border-opacity))
|
||||
}
|
||||
|
||||
.border-t {
|
||||
border-top-width: 1px
|
||||
}
|
||||
|
||||
.border-r {
|
||||
border-right-width: 1px
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600
|
||||
}
|
||||
|
||||
.h-5 {
|
||||
height: 1.25rem
|
||||
}
|
||||
|
||||
.h-8 {
|
||||
height: 2rem
|
||||
}
|
||||
|
||||
.h-16 {
|
||||
height: 4rem
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: .875rem
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem
|
||||
}
|
||||
|
||||
.leading-7 {
|
||||
line-height: 1.75rem
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: .25rem
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: .5rem
|
||||
}
|
||||
|
||||
.mr-2 {
|
||||
margin-right: .5rem
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: .5rem
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 1rem
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 2rem
|
||||
}
|
||||
|
||||
.ml-12 {
|
||||
margin-left: 3rem
|
||||
}
|
||||
|
||||
.-mt-px {
|
||||
margin-top: -1px
|
||||
}
|
||||
|
||||
.max-w-xl {
|
||||
max-width: 36rem
|
||||
}
|
||||
|
||||
.max-w-6xl {
|
||||
max-width: 72rem
|
||||
}
|
||||
|
||||
.min-h-screen {
|
||||
min-height: 100vh
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem
|
||||
}
|
||||
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem
|
||||
}
|
||||
|
||||
.pt-8 {
|
||||
padding-top: 2rem
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative
|
||||
}
|
||||
|
||||
.top-0 {
|
||||
top: 0
|
||||
}
|
||||
|
||||
.right-0 {
|
||||
right: 0
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .1), 0 1px 2px 0 rgba(0, 0, 0, .06)
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center
|
||||
}
|
||||
|
||||
.text-gray-200 {
|
||||
--text-opacity: 1;
|
||||
color: #edf2f7;
|
||||
color: rgba(237, 242, 247, var(--text-opacity))
|
||||
}
|
||||
|
||||
.text-gray-300 {
|
||||
--text-opacity: 1;
|
||||
color: #e2e8f0;
|
||||
color: rgba(226, 232, 240, var(--text-opacity))
|
||||
}
|
||||
|
||||
.text-gray-400 {
|
||||
--text-opacity: 1;
|
||||
color: #cbd5e0;
|
||||
color: rgba(203, 213, 224, var(--text-opacity))
|
||||
}
|
||||
|
||||
.text-gray-500 {
|
||||
--text-opacity: 1;
|
||||
color: #a0aec0;
|
||||
color: rgba(160, 174, 192, var(--text-opacity))
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
--text-opacity: 1;
|
||||
color: #718096;
|
||||
color: rgba(113, 128, 150, var(--text-opacity))
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
--text-opacity: 1;
|
||||
color: #4a5568;
|
||||
color: rgba(74, 85, 104, var(--text-opacity))
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
--text-opacity: 1;
|
||||
color: #1a202c;
|
||||
color: rgba(26, 32, 44, var(--text-opacity))
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration: underline
|
||||
}
|
||||
|
||||
.antialiased {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
}
|
||||
|
||||
.tracking-wider {
|
||||
letter-spacing: .05em
|
||||
}
|
||||
|
||||
.w-5 {
|
||||
width: 1.25rem
|
||||
}
|
||||
|
||||
.w-8 {
|
||||
width: 2rem
|
||||
}
|
||||
|
||||
.w-auto {
|
||||
width: auto
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr))
|
||||
}
|
||||
|
||||
@-webkit-keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg)
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(1turn)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg)
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(1turn)
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes ping {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
75%,
|
||||
to {
|
||||
transform: scale(2);
|
||||
opacity: 0
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ping {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
75%,
|
||||
to {
|
||||
transform: scale(2);
|
||||
opacity: 0
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes pulse {
|
||||
|
||||
0%,
|
||||
to {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: .5
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
to {
|
||||
opacity: 1
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: .5
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes bounce {
|
||||
|
||||
0%,
|
||||
to {
|
||||
transform: translateY(-25%);
|
||||
-webkit-animation-timing-function: cubic-bezier(.8, 0, 1, 1);
|
||||
animation-timing-function: cubic-bezier(.8, 0, 1, 1)
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(0);
|
||||
-webkit-animation-timing-function: cubic-bezier(0, 0, .2, 1);
|
||||
animation-timing-function: cubic-bezier(0, 0, .2, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
to {
|
||||
transform: translateY(-25%);
|
||||
-webkit-animation-timing-function: cubic-bezier(.8, 0, 1, 1);
|
||||
animation-timing-function: cubic-bezier(.8, 0, 1, 1)
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(0);
|
||||
-webkit-animation-timing-function: cubic-bezier(0, 0, .2, 1);
|
||||
animation-timing-function: cubic-bezier(0, 0, .2, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width:640px) {
|
||||
.sm\:rounded-lg {
|
||||
border-radius: .5rem
|
||||
}
|
||||
|
||||
.sm\:block {
|
||||
display: block
|
||||
}
|
||||
|
||||
.sm\:items-center {
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.sm\:justify-start {
|
||||
justify-content: flex-start
|
||||
}
|
||||
|
||||
.sm\:justify-between {
|
||||
justify-content: space-between
|
||||
}
|
||||
|
||||
.sm\:h-20 {
|
||||
height: 5rem
|
||||
}
|
||||
|
||||
.sm\:ml-0 {
|
||||
margin-left: 0
|
||||
}
|
||||
|
||||
.sm\:px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem
|
||||
}
|
||||
|
||||
.sm\:pt-0 {
|
||||
padding-top: 0
|
||||
}
|
||||
|
||||
.sm\:text-left {
|
||||
text-align: left
|
||||
}
|
||||
|
||||
.sm\:text-right {
|
||||
text-align: right
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width:768px) {
|
||||
.md\:border-t-0 {
|
||||
border-top-width: 0
|
||||
}
|
||||
|
||||
.md\:border-l {
|
||||
border-left-width: 1px
|
||||
}
|
||||
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr))
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width:1024px) {
|
||||
.lg\:px-8 {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme:dark) {
|
||||
.dark\:bg-gray-800 {
|
||||
--bg-opacity: 1;
|
||||
background-color: #2d3748;
|
||||
background-color: rgba(45, 55, 72, var(--bg-opacity))
|
||||
}
|
||||
|
||||
.dark\:bg-gray-900 {
|
||||
--bg-opacity: 1;
|
||||
background-color: #1a202c;
|
||||
background-color: rgba(26, 32, 44, var(--bg-opacity))
|
||||
}
|
||||
|
||||
.dark\:border-gray-700 {
|
||||
--border-opacity: 1;
|
||||
border-color: #4a5568;
|
||||
border-color: rgba(74, 85, 104, var(--border-opacity))
|
||||
}
|
||||
|
||||
.dark\:text-white {
|
||||
--text-opacity: 1;
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, var(--text-opacity))
|
||||
}
|
||||
|
||||
.dark\:text-gray-300 {
|
||||
--text-opacity: 1;
|
||||
color: #e2e8f0;
|
||||
color: rgba(226, 232, 240, var(--text-opacity))
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="antialiased bg-white text-black">
|
||||
<div class="relative flex items-top justify-center min-h-screen bg-white text-black sm:items-center sm:pt-0"
|
||||
role="main">
|
||||
<div class="max-w-xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="flex items-center pt-8 sm:justify-start sm:pt-0">
|
||||
<h1 class="px-4 text-lg text-black border-r border-gray-400">
|
||||
@yield('code')
|
||||
</h1>
|
||||
|
||||
<div class="ml-4 text-lg text-black">
|
||||
@yield('message')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<title>biiproject - Login</title>
|
||||
<link rel="icon" type="image/png"
|
||||
href="{{ asset($app_favicon) }}?v={{ file_exists(public_path($app_favicon ?? '')) ? filemtime(public_path($app_favicon ?? '')) : time() }}">
|
||||
|
||||
{{-- Font --}}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600&family=Outfit:wght@600&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
@stack('styles')
|
||||
<link href="{{ asset('assets/css/app.css') }}" rel="stylesheet">
|
||||
<script defer src="{{ asset('assets/js/app.js') }}"></script>
|
||||
<style>
|
||||
:root {
|
||||
--adminuiux-content-font: "Open Sans", sans-serif;
|
||||
--adminuiux-title-font: "Outfit", sans-serif;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.pageloader {
|
||||
background: #262e38 !important;
|
||||
backdrop-filter: blur(0.5px);
|
||||
}
|
||||
|
||||
/* Modern Pill Design System */
|
||||
.swal2-popup.modern-pill-popup {
|
||||
border-radius: 30px !important;
|
||||
font-family: 'Outfit', sans-serif !important;
|
||||
padding: 2rem !important;
|
||||
}
|
||||
|
||||
.swal2-title {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.swal2-html-container {
|
||||
font-size: 0.95rem !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-pill-primary {
|
||||
background-color: #1e1e1e !important;
|
||||
color: white !important;
|
||||
border-radius: 50px !important;
|
||||
padding: 10px 30px !important;
|
||||
font-weight: 600 !important;
|
||||
border: none !important;
|
||||
margin: 0 5px !important;
|
||||
}
|
||||
|
||||
.btn-pill-danger {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
border-radius: 50px !important;
|
||||
padding: 10px 30px !important;
|
||||
font-weight: 600 !important;
|
||||
border: none !important;
|
||||
margin: 0 5px !important;
|
||||
}
|
||||
|
||||
.btn-pill-cancel {
|
||||
background-color: white !important;
|
||||
color: #1e1e1e !important;
|
||||
border: 2px solid #1e1e1e !important;
|
||||
border-radius: 50px !important;
|
||||
padding: 8px 28px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 5px !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
--bs-btn-color: #fff !important;
|
||||
--bs-btn-bg: #1e1e1e !important;
|
||||
--bs-btn-border-color: #1e1e1e !important;
|
||||
--bs-btn-hover-color: #fff !important;
|
||||
--bs-btn-hover-bg: #000 !important;
|
||||
--bs-btn-hover-border-color: #000 !important;
|
||||
--bs-btn-focus-shadow-rgb: 30, 30, 30 !important;
|
||||
--bs-btn-active-color: #fff !important;
|
||||
--bs-btn-active-bg: #000 !important;
|
||||
--bs-btn-active-border-color: #000 !important;
|
||||
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, .125) !important;
|
||||
--bs-btn-disabled-color: #fff !important;
|
||||
--bs-btn-disabled-bg: #1e1e1e !important;
|
||||
--bs-btn-disabled-border-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
--bs-btn-color: #1e1e1e !important;
|
||||
--bs-btn-border-color: #1e1e1e !important;
|
||||
--bs-btn-hover-color: #fff !important;
|
||||
--bs-btn-hover-bg: #1e1e1e !important;
|
||||
--bs-btn-hover-border-color: #1e1e1e !important;
|
||||
--bs-btn-focus-shadow-rgb: 30, 30, 30 !important;
|
||||
--bs-btn-active-color: #fff !important;
|
||||
--bs-btn-active-bg: #1e1e1e !important;
|
||||
--bs-btn-active-border-color: #1e1e1e !important;
|
||||
}
|
||||
|
||||
/* Standardized Modern Pill Buttons */
|
||||
.btn-pill-standard-primary {
|
||||
background-color: #ffffff !important;
|
||||
color: #212529 !important;
|
||||
border: 1px solid #212529 !important;
|
||||
border-radius: 50rem !important;
|
||||
padding: 8px 24px !important;
|
||||
font-weight: 500 !important;
|
||||
font-family: var(--adminuiux-title-font) !important;
|
||||
min-width: 110px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.btn-pill-standard-primary:hover {
|
||||
background-color: #f8f9fa !important;
|
||||
border-color: #000000 !important;
|
||||
}
|
||||
|
||||
/* Global Zoom */
|
||||
/* html {
|
||||
zoom: 0.8;
|
||||
} */
|
||||
|
||||
/* Global 100% Fade-In Policy - Without Exception */
|
||||
.adminuiux-wrap,
|
||||
.card,
|
||||
.adminuiux-content,
|
||||
.login-box {
|
||||
animation: fadeIn 0.6s ease-out both !important;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="main-bg main-bg-opac roundedui adminuiux-header-boxed adminuiux-header-transparent adminuiux-sidebar-fill-white adminuiux-sidebar-boxed theme-black bg-gradient-1 scrollup"
|
||||
data-theme="theme-black">
|
||||
{{-- page loader --}}
|
||||
<div class="pageloader">
|
||||
<div class="container h-100">
|
||||
<div class="row justify-content-center align-items-center text-center h-100">
|
||||
<div class="col-12 mb-auto pt-4"></div>
|
||||
<div class="col-auto">
|
||||
<div class="loader5 mb-2 mx-auto"></div>
|
||||
</div>
|
||||
<div class="col-12 mt-auto pb-4">
|
||||
<p class="text-secondary">Please wait for awesome things...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adminuiux-wrap z-index-0 position-relative bg-theme-1 ">
|
||||
<figure class="position-absolute top-0 start-0 w-100 h-100 coverimg z-index-0">
|
||||
<img style="-webkit-filter: invert(1);filter: invert(1);"
|
||||
src="{{ asset('assets/img/background-image/bg1.png') }}" alt="">
|
||||
</figure>
|
||||
<main class="adminuiux-content z-index-1 position-relative animate__animated animate__fadeIn">
|
||||
<div class="container-fluid">
|
||||
<div class="row align-items-center justify-content-center mt-auto z-index-1 height-dynamic"
|
||||
style="--h-dynamic: calc(100vh - 60px)">
|
||||
<div class="col login-box {{ $attributes->get('maxWidthClass', 'maxwidth-400') }} text-dark pb-ios">
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@stack('modals')
|
||||
|
||||
{{-- page js --}}
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
|
||||
<script src="{{ asset('assets/js/mobileux/mobileux-auth.js') }}"></script>
|
||||
<script src="{{ asset('assets/js/password-toggle.js') }}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
// Global Modern Pill Mixin for Guest Layout
|
||||
window.StandardSwal = Swal.mixin({
|
||||
customClass: {
|
||||
popup: 'modern-pill-popup',
|
||||
confirmButton: 'btn-pill-primary',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
buttonsStyling: false,
|
||||
confirmButtonText: 'OK',
|
||||
cancelButtonText: 'Cancel',
|
||||
showClass: { popup: 'animate__animated animate__fadeIn animate__faster' },
|
||||
hideClass: { popup: 'animate__animated animate__fadeOutDown animate__faster' }
|
||||
});
|
||||
</script>
|
||||
@if (session('success'))
|
||||
<script>
|
||||
StandardSwal.fire({
|
||||
icon: 'success',
|
||||
title: 'Success!',
|
||||
text: "{{ session('success') }}",
|
||||
timer: 2500,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
|
||||
@if (session('info'))
|
||||
<script>
|
||||
StandardSwal.fire({
|
||||
icon: 'info',
|
||||
title: 'Information',
|
||||
text: "{{ session('info') }}",
|
||||
timer: 3000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
|
||||
@if (session('error'))
|
||||
<script>
|
||||
StandardSwal.fire({
|
||||
icon: 'error',
|
||||
title: 'System Notice',
|
||||
text: "{{ session('error') }}",
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
|
||||
@stack('scripts')
|
||||
|
||||
{{-- Cookie Consent Banner --}}
|
||||
@include('layouts.partials.cookie-banner')
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,384 @@
|
||||
<div class="adminuiux-wrap">
|
||||
{{-- Standard sidebar --}}
|
||||
<div class="adminuiux-sidebar shadow-sm">
|
||||
<div class="adminuiux-sidebar-inner">
|
||||
<ul class="nav flex-column menu-active-line mt-3">
|
||||
|
||||
{{-- INSTANCE MODE --}}
|
||||
<li id="sidebar-mode-container"
|
||||
class="nav-item px-3 mb-2 mt-2 {{ ($instance_mode ?? 'Production') === 'Production' ? 'd-none' : '' }}">
|
||||
@php
|
||||
$modeClass = match ($instance_mode ?? 'Production') {
|
||||
'Demo' => 'bg-info text-dark',
|
||||
'Trial' => 'bg-warning text-dark',
|
||||
default => 'bg-success text-white'
|
||||
};
|
||||
@endphp
|
||||
<div id="sidebar-mode-badge"
|
||||
class="badge {{ $modeClass }} w-100 py-2 rounded-pill fw-bold shadow-sm d-flex align-items-center justify-content-center"
|
||||
style="letter-spacing: 0.5px;">
|
||||
<i class="bi bi-circle-fill me-2" style="font-size: 0.5rem; opacity: 0.8;"></i>
|
||||
<span id="sidebar-mode-text">{{ strtoupper($instance_mode ?? 'Production') }}</span>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{{-- GROUP: OVERVIEW --}}
|
||||
<li class="nav-label text-uppercase text-secondary small fw-bold px-4 mb-2 mt-3"
|
||||
style="letter-spacing: 1px; opacity: 0.6; font-size: 0.7rem;">{{ __('OVERVIEW') }}</li>
|
||||
|
||||
{{-- DASHBOARD --}}
|
||||
@can('view dashboard')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('dashboard') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('dashboard') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-cpu me-2"></i>
|
||||
<span class="menu-name">Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
{{-- NOTIFICATION CENTER --}}
|
||||
@php
|
||||
$isNotificationEnabled = get_setting('feature_notification_center', true);
|
||||
$canManageSettings = Auth::user()?->can('manage global settings');
|
||||
$canViewNotifications = Auth::user()?->can('view notification center');
|
||||
|
||||
// Show if feature is on OR if user is an admin (who can see it even if disabled)
|
||||
$showNotificationMenu = ($isNotificationEnabled && $canViewNotifications) || $canManageSettings;
|
||||
@endphp
|
||||
|
||||
@if($showNotificationMenu)
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('notification-center.index') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('notification-center*') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-bell me-2"></i>
|
||||
<span class="menu-name">Notification</span>
|
||||
</a>
|
||||
</li>
|
||||
@endif
|
||||
|
||||
{{-- GROUP: ACCESS --}}
|
||||
@canany(['view user directory', 'view access rights', 'view active sessions'])
|
||||
<li class="nav-label text-uppercase text-secondary small fw-bold px-4 mb-2 mt-4"
|
||||
style="letter-spacing: 1px; opacity: 0.6; font-size: 0.7rem;">{{ __('ACCESS') }}</li>
|
||||
|
||||
@php
|
||||
$isUserSecurityActive = request()->routeIs('users*') || request()->routeIs('roles*') || request()->routeIs('session-manager*');
|
||||
@endphp
|
||||
<li class="nav-item">
|
||||
<button type="button"
|
||||
id="btn-user-security-toggle"
|
||||
class="nav-link d-flex align-items-center w-100 border-0 bg-transparent text-start"
|
||||
data-sidebar-toggle="userSecuritySubmenu"
|
||||
aria-expanded="{{ $isUserSecurityActive ? 'true' : 'false' }}">
|
||||
<i class="menu-icon bi bi-person-lock me-2"></i>
|
||||
<span class="menu-name">User & Security</span>
|
||||
<i class="bi bi-chevron-down ms-auto x-small" id="chevron-user-security"></i>
|
||||
</button>
|
||||
<ul class="nav flex-column ps-4 mb-2" id="userSecuritySubmenu"
|
||||
style="{{ $isUserSecurityActive ? '' : 'display:none;' }}overflow:hidden;transition:all 0.2s ease;">
|
||||
@can('view user directory')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('users') }}"
|
||||
class="nav-link py-1 {{ request()->routeIs('users*') ? 'active' : '' }}">
|
||||
<i class="bi bi-circle me-2 small" style="font-size: 0.5rem;"></i>
|
||||
<span class="menu-name small">User Directory</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
@can('view access rights')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('roles') }}"
|
||||
class="nav-link py-1 {{ request()->routeIs('roles*') || request()->routeIs('permissions*') ? 'active' : '' }}">
|
||||
<i class="bi bi-circle me-2 small" style="font-size: 0.5rem;"></i>
|
||||
<span class="menu-name small">Access Rights</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
@can('view active sessions')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('session-manager') }}"
|
||||
class="nav-link py-1 {{ request()->routeIs('session-manager*') ? 'active' : '' }}">
|
||||
<i class="bi bi-circle me-2 small" style="font-size: 0.5rem;"></i>
|
||||
<span class="menu-name small">Active Sessions</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
</ul>
|
||||
</li>
|
||||
@endcanany
|
||||
|
||||
{{-- GROUP: MONITORING --}}
|
||||
@canany(['view health and logs', 'view action history'])
|
||||
<li class="nav-label text-uppercase text-secondary small fw-bold px-4 mb-2 mt-4"
|
||||
style="letter-spacing: 1px; opacity: 0.6; font-size: 0.7rem;">{{ __('MONITORING') }}</li>
|
||||
|
||||
{{-- MONITORING --}}
|
||||
@can('view health and logs')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('system-monitoring') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('system-monitoring') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-speedometer2 me-2"></i>
|
||||
<span class="menu-name">Health & Logs</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
{{-- ACTION LOGS --}}
|
||||
@can('view action history')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('action-logs') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('action-logs*') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-journal-text me-2"></i>
|
||||
<span class="menu-name">Action History</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
@endcanany
|
||||
|
||||
{{-- GROUP: CONFIGURATION --}}
|
||||
@canany(['view backup and storage', 'view maintenance mode', 'view global settings', 'view mobile settings'])
|
||||
<li class="nav-label text-uppercase text-secondary small fw-bold px-4 mb-2 mt-4"
|
||||
style="letter-spacing: 1px; opacity: 0.6; font-size: 0.7rem;">{{ __('CONFIGURATION') }}</li>
|
||||
|
||||
@can('view backup and storage')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('backup-restore.index') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('backup-restore.*') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-database-check me-2"></i>
|
||||
<span class="menu-name">Backup & Storage</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
@can('view maintenance mode')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('maintenance-mode') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('maintenance-mode') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-exclamation-octagon me-2"></i>
|
||||
<span class="menu-name">Maintenance</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
@can('view global settings')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('system-config') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('system-config') && !request()->has('anchor') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-sliders me-2"></i>
|
||||
<span class="menu-name">Global Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
|
||||
|
||||
@can('view mobile settings')
|
||||
<li class="nav-item">
|
||||
<a href="{{ route('mobile-settings.index') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('mobile-settings*') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-phone-vibrate me-2"></i>
|
||||
<span class="menu-name">Mobile Settings</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
@endcanany
|
||||
|
||||
{{-- GROUP: DEVELOPER --}}
|
||||
@canany(['view pulse', 'view telescope', 'view api docs'])
|
||||
@php
|
||||
$pulseEnabled = (bool) get_setting('engine_pulse_enabled', true);
|
||||
$telescopeEnabled = (bool) get_setting('engine_telescope_enabled', true);
|
||||
$swaggerEnabled = (bool) get_setting('engine_swagger_enabled', true);
|
||||
$horizonEnabled = (bool) get_setting('engine_horizon_enabled', true);
|
||||
$aiDiagEnabled = Auth::user()?->can('view ai self-healing')
|
||||
? (bool) get_setting('ai_healing_enabled', false)
|
||||
: false;
|
||||
$showDevGroup = $pulseEnabled || $telescopeEnabled || $swaggerEnabled || $horizonEnabled || $aiDiagEnabled;
|
||||
@endphp
|
||||
|
||||
<li id="sidebar-dev-group"
|
||||
class="nav-label text-uppercase text-secondary small fw-bold px-4 mb-2 mt-4 {{ !$showDevGroup ? 'd-none' : '' }}"
|
||||
style="letter-spacing: 1px; opacity: 0.6; font-size: 0.7rem;">{{ __('DEVELOPER') }}</li>
|
||||
|
||||
{{-- 0. AI DIAGNOSTICS --}}
|
||||
@can('view ai self-healing')
|
||||
<li id="menu-ai-diagnostics" class="nav-item {{ !$aiDiagEnabled ? 'd-none' : '' }}"
|
||||
x-data="{ pending: 0 }" x-init="
|
||||
const _fetchAiStats = () => fetch('{{ route('ai-self-healing.stats') }}').then(r=>r.json()).then(d=>{ pending = d.pending||0; }).catch(()=>{});
|
||||
_fetchAiStats();
|
||||
setInterval(_fetchAiStats, 30000);
|
||||
">
|
||||
<a href="{{ route('ai-self-healing.index') }}"
|
||||
class="nav-link d-flex align-items-center {{ request()->routeIs('ai-self-healing.*') ? 'active' : '' }}">
|
||||
<i class="menu-icon bi bi-robot me-2 text-danger"></i>
|
||||
<span class="menu-name">AI Diagnostics</span>
|
||||
<span x-show="pending > 0" x-text="pending > 9 ? '9+' : pending"
|
||||
class="badge bg-warning text-dark ms-auto rounded-pill"
|
||||
style="font-size:.6rem;min-width:18px;padding:2px 5px;" x-cloak></span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
{{-- 1. HORIZON QUEUE MONITOR --}}
|
||||
@can('manage global settings')
|
||||
<li id="menu-horizon" class="nav-item {{ !$horizonEnabled ? 'd-none' : '' }}">
|
||||
<a href="/horizon" target="_blank" class="nav-link d-flex align-items-center">
|
||||
<i class="menu-icon bi bi-speedometer2 me-2 text-primary"></i>
|
||||
<span class="menu-name">Horizon</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
{{-- 2. PULSE MONITORING --}}
|
||||
@can('view pulse')
|
||||
<li id="menu-pulse" class="nav-item {{ !$pulseEnabled ? 'd-none' : '' }}">
|
||||
<a href="/pulse" target="_blank" class="nav-link d-flex align-items-center">
|
||||
<i class="menu-icon bi bi-activity me-2 text-info"></i>
|
||||
<span class="menu-name">Pulse</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
{{-- 3. TELESCOPE DEBUGGER --}}
|
||||
@can('view telescope')
|
||||
<li id="menu-telescope" class="nav-item {{ !$telescopeEnabled ? 'd-none' : '' }}">
|
||||
<a href="/telescope" target="_blank" class="nav-link d-flex align-items-center">
|
||||
<i class="menu-icon bi bi-bug me-2 text-warning"></i>
|
||||
<span class="menu-name">Telescope</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
|
||||
{{-- 4. API DOCUMENTATION --}}
|
||||
@can('view api docs')
|
||||
<li id="menu-swagger" class="nav-item {{ !$swaggerEnabled ? 'd-none' : '' }}">
|
||||
<a href="/api/documentation" target="_blank" class="nav-link d-flex align-items-center">
|
||||
<i class="menu-icon bi bi-code-square me-2 text-success"></i>
|
||||
<span class="menu-name">API Doc</span>
|
||||
</a>
|
||||
</li>
|
||||
@endcan
|
||||
@endcanany
|
||||
|
||||
<hr class="mx-3 opacity-10">
|
||||
|
||||
{{-- LOGOUT --}}
|
||||
<li class="nav-item">
|
||||
<a href="javascript:void(0)" class="nav-link d-flex align-items-center text-danger"
|
||||
onclick="confirmLogout();">
|
||||
<i class="menu-icon bi bi-box-arrow-right me-2"></i>
|
||||
<span class="menu-name">Logout Session</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
|
||||
@csrf
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function confirmLogout() {
|
||||
StandardSwal.fire({
|
||||
title: 'Confirm Logout',
|
||||
text: 'Are you sure you want to log out from your session?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: 'Yes, Logout',
|
||||
cancelButtonText: 'Cancel',
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
document.getElementById('logout-form').submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
</ul>
|
||||
|
||||
<div class=" mt-auto "></div>
|
||||
|
||||
<div class="container mt-3 mt-lg-4" id="main-content" style="margin-top: 0rem !important;">
|
||||
<div class="row gx-3 align-items-center ms-4">
|
||||
<div class="col mb-3 mb-lg-4">
|
||||
<div class="row gx-2 align-items-center">
|
||||
<div class="col-auto col-md-auto text-center">
|
||||
<a href="{{ route('profile.edit') }}" class="style-none position-relative d-block">
|
||||
<figure class="avatar avatar-50 rounded coverimg align-middle">
|
||||
<img src="{{ asset('assets/img/profile.png') }}" alt="">
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h5 class="fw-medium mb-0">Hello,</h5>
|
||||
<h3><span class="text-theme-1">{{ Auth::user()->name ?? 'User' }}</span></h3>
|
||||
</div>
|
||||
<div class="col-auto text-end mb-3 mb-lg-4">
|
||||
<a href="mobileux-wallet-sendmoney.html" class="style-none">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{-- adminuiux-wrap closed in app.blade.php after <main> so that .adminuiux-content sits inside the wrap --}}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function initSidebarSubmenus() {
|
||||
var buttons = document.querySelectorAll('[data-sidebar-toggle]');
|
||||
buttons.forEach(function (btn) {
|
||||
// Remove any existing listeners to prevent duplicates
|
||||
var newBtn = btn.cloneNode(true);
|
||||
btn.parentNode.replaceChild(newBtn, btn);
|
||||
|
||||
newBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
|
||||
var targetId = this.getAttribute('data-sidebar-toggle');
|
||||
var submenu = document.getElementById(targetId);
|
||||
var chevron = this.querySelector('.bi-chevron-down');
|
||||
|
||||
if (!submenu) return;
|
||||
|
||||
var isOpen = submenu.style.display !== 'none';
|
||||
|
||||
if (isOpen) {
|
||||
submenu.style.display = 'none';
|
||||
this.setAttribute('aria-expanded', 'false');
|
||||
if (chevron) chevron.style.transform = 'rotate(0deg)';
|
||||
} else {
|
||||
submenu.style.display = 'block';
|
||||
this.setAttribute('aria-expanded', 'true');
|
||||
if (chevron) chevron.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial chevron state based on current aria-expanded
|
||||
var chevron = newBtn.querySelector('.bi-chevron-down');
|
||||
if (chevron) {
|
||||
var expanded = newBtn.getAttribute('aria-expanded') === 'true';
|
||||
chevron.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
|
||||
chevron.style.transition = 'transform 0.2s ease';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSidebarSubmenus);
|
||||
} else {
|
||||
initSidebarSubmenus();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,101 @@
|
||||
@if(get_setting('feature_cookie_banner', true))
|
||||
<div id="cookie_consent_banner" class="cookie-banner-wrapper d-none animate__animated animate__fadeIn">
|
||||
<div class="cookie-banner-card card adminuiux-card p-4 shadow-lg border-0">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-12 col-md-auto mb-3 mb-md-0">
|
||||
<div class="avatar avatar-50 rounded-circle bg-warning-subtle text-warning">
|
||||
<i class="bi bi-cookie fs-3"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md">
|
||||
<h6 class="fw-bold mb-1">{{ __('We use cookies') }}</h6>
|
||||
<p class="small text-muted mb-0">
|
||||
{{ __('We use cookies to enhance your experience and analyze our traffic. By clicking "Accept", you consent to our use of cookies as described in our') }}
|
||||
<a href="{{ route('legal.show', 'privacy') }}" class="text-primary fw-bold text-decoration-none">{{ __('Privacy Policy') }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-12 col-md-auto mt-3 mt-md-0">
|
||||
<div class="btn-group gap-2">
|
||||
<button type="button" onclick="acceptCookies()" class="btn btn-pill-primary px-4">{{ __('Accept') }}</button>
|
||||
<button type="button" onclick="dismissCookieBanner()" class="btn btn-pill-cancel px-3">{{ __('Dismiss') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cookie-banner-wrapper {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
z-index: 9999;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.cookie-banner-card {
|
||||
border-radius: 30px !important;
|
||||
background: rgba(255, 255, 255, 0.8) !important;
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4) !important;
|
||||
box-shadow: 0 40px 80px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.cookie-banner-wrapper {
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
.cookie-banner-card {
|
||||
border-radius: 25px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function getCookie(name) {
|
||||
let nameEQ = name + "=";
|
||||
let ca = document.cookie.split(';');
|
||||
for(let i=0;i < ca.length;i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0)==' ') c = c.substring(1,c.length);
|
||||
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(name,value,days) {
|
||||
let expires = "";
|
||||
if (days) {
|
||||
let date = new Date();
|
||||
date.setTime(date.getTime() + (days*24*60*60*1000));
|
||||
expires = "; expires=" + date.toUTCString();
|
||||
}
|
||||
document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax";
|
||||
}
|
||||
|
||||
function acceptCookies() {
|
||||
setCookie('cookie_consent', 'accepted', 365);
|
||||
dismissCookieBanner();
|
||||
}
|
||||
|
||||
function dismissCookieBanner() {
|
||||
const banner = document.getElementById('cookie_consent_banner');
|
||||
if (banner) {
|
||||
banner.classList.remove('animate__fadeIn');
|
||||
banner.classList.add('animate__fadeOutDown');
|
||||
setTimeout(() => banner.classList.add('d-none'), 500);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
if (!getCookie('cookie_consent')) {
|
||||
setTimeout(() => {
|
||||
const banner = document.getElementById('cookie_consent_banner');
|
||||
if (banner) banner.classList.remove('d-none');
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||
<script>
|
||||
(function() {
|
||||
const appearance = '{{ $appearance ?? "system" }}';
|
||||
|
||||
if (appearance === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
if (prefersDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: oklch(0.145 0 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>Authorize Application - {{ config('app.name', 'MCP Server') }}</title>
|
||||
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Authorize MCP" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
<body class="font-sans antialiased bg-background text-foreground">
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Card Container -->
|
||||
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<div class="flex items-center justify-center mb-4">
|
||||
<!-- Shield Icon -->
|
||||
<svg class="h-12 w-12 text-primary" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.618 5.984A11.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.031 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold leading-none tracking-tight text-center">
|
||||
Authorize {{ $client->name }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
This application will be able to:<br/>Use available MCP functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 pt-0 space-y-4">
|
||||
<!-- User Info -->
|
||||
<div class="rounded-lg border p-4 bg-muted/50">
|
||||
<p class="text-sm text-muted-foreground mb-2">Logged in as:</p>
|
||||
<p class="font-medium">{{ $user->email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Scopes / Permissions -->
|
||||
@if(count($scopes) > 0)
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Permissions:</p>
|
||||
|
||||
<ul class="space-y-2">
|
||||
@foreach($scopes as $scope)
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="rounded-full bg-primary/10 p-1 mt-0.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $scope->description }}
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Footer With Buttons -->
|
||||
<div class="flex items-center p-6 pt-0 gap-3">
|
||||
<!-- Deny Form -->
|
||||
<form method="POST" action="{{ route('passport.authorizations.deny') }}" class="flex-1">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<input type="hidden" name="state" value="">
|
||||
<input type="hidden" name="client_id" value="{{ $client->id }}">
|
||||
<input type="hidden" name="auth_token" value="{{ $authToken }}">
|
||||
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-full">
|
||||
<svg class="mr-2 h-4 w-4" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Approve Form -->
|
||||
<form method="POST" action="{{ route('passport.authorizations.approve') }}" class="flex-1" id="authorizeForm">
|
||||
@csrf
|
||||
<input type="hidden" name="state" value="">
|
||||
<input type="hidden" name="client_id" value="{{ $client->id }}">
|
||||
<input type="hidden" name="auth_token" value="{{ $authToken }}">
|
||||
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full" id="authorizeButton">
|
||||
<span id="authorizeText">Authorize</span>
|
||||
|
||||
<svg id="loadingSpinner" class="animate-spin -ml-1 mr-3 h-4 w-4 text-white hidden" 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>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('authorizeForm');
|
||||
const button = document.getElementById('authorizeButton');
|
||||
const authorizeText = document.getElementById('authorizeText');
|
||||
const loadingSpinner = document.getElementById('loadingSpinner');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Show loading state...
|
||||
button.disabled = true;
|
||||
authorizeText.textContent = 'Authorizing...';
|
||||
loadingSpinner.classList.remove('hidden');
|
||||
|
||||
// After form submission, watch for redirect and close window...
|
||||
setTimeout(function() {
|
||||
const checkRedirect = setInterval(function() {
|
||||
// If URL changed or we have OAuth params, redirect happened...
|
||||
if (!window.location.href.includes('/oauth/authorize') ||
|
||||
window.location.search.includes('code=') ||
|
||||
window.location.search.includes('error=')) {
|
||||
clearInterval(checkRedirect);
|
||||
window.close();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Fallback: Close after five seconds...
|
||||
setTimeout(function() {
|
||||
clearInterval(checkRedirect);
|
||||
window.close();
|
||||
}, 5000);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Handle cancel button...
|
||||
const cancelForm = document.querySelector('form[method="POST"]:has(input[name="_method"][value="DELETE"])');
|
||||
if (cancelForm) {
|
||||
cancelForm.addEventListener('submit', function(e) {
|
||||
setTimeout(function() {
|
||||
window.close();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,323 @@
|
||||
<x-app-layout>
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- Action Log Header --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('Action Log') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Monitor all recorded user actions within the system, including authentication and data modifications.') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="export-csv-btn">
|
||||
<i class="bi bi-file-earmark-excel me-1"></i> {{ __('Export CSV') }}
|
||||
</button>
|
||||
@can('manage action history')
|
||||
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3" id="clear-activity-logs-btn">
|
||||
<i class="bi bi-trash me-1"></i> {{ __('Clear All Logs') }}
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mb-4">
|
||||
<button type="button" class="btn btn-sm btn-dark rounded-pill px-3 filter-event" data-value="">
|
||||
<i class="bi bi-list-ul me-1"></i> {{ __('All Logs') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-event" data-value="auth">
|
||||
<i class="bi bi-shield-lock me-1"></i> {{ __('Authentication') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-event" data-value="data">
|
||||
<i class="bi bi-database-gear me-1"></i> {{ __('Data Modification') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-event" data-value="system">
|
||||
<i class="bi bi-cpu me-1"></i> {{ __('System') }}
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="filter-event-val" name="event" class="filter-extra" value="">
|
||||
</div>
|
||||
|
||||
{{-- Action Log Table --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('action-logs') }}"
|
||||
data-order='@json([[4, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-wrap">{{ __('User') }}</th>
|
||||
<th class="text-wrap">{{ __('Action') }}</th>
|
||||
<th class="text-wrap">{{ __('Preview') }}</th>
|
||||
<th class="text-wrap">{{ __('Module') }}</th>
|
||||
<th class="text-wrap">{{ __('Executed At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('IP Address') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Device / Agent') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Properties') }}</th>
|
||||
<th class="text-end text-wrap" data-orderable="false"
|
||||
data-searchable="false">{{ __('Details') }}</th>
|
||||
</tr>
|
||||
|
||||
<tr class="filter-row">
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search User') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Action') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Keyword') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Module') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('IP') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Agent') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Props') }}"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ADVANCED AUDIT MODAL --}}
|
||||
<div class="modal fade" id="detailLogModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow-lg" style="border-radius: 1.5rem;">
|
||||
<div class="modal-header border-0 p-4 pb-0">
|
||||
<div>
|
||||
<h5 class="modal-title fw-black tracking-tight" id="modal-event-label">Action Log Detail</h5>
|
||||
<div id="modal-module-info" class="text-theme-1 small fw-bold text-uppercase"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
|
||||
{{-- Initiator & Time --}}
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 bg-light rounded-4 h-100 shadow-sm border border-white">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-2 d-block opacity-50">{{ __('Initiator') }}</label>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="avatar avatar-40 rounded-circle bg-white shadow-sm d-flex align-items-center justify-content-center text-theme-1">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div id="modal-causer-name" class="fw-bold text-dark"></div>
|
||||
<div id="modal-causer-email" class="text-secondary extra-small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 bg-light rounded-4 h-100 shadow-sm border border-white">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-2 d-block opacity-50">{{ __('Timestamp & Origin') }}</label>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="avatar avatar-40 rounded-circle bg-white shadow-sm d-flex align-items-center justify-content-center text-info">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div id="modal-time-info" class="fw-bold text-dark small"></div>
|
||||
<div id="modal-network-info" class="text-secondary extra-small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Data Changes List (Professional Diff Style) --}}
|
||||
<div class="mb-4">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-3 d-block opacity-50 ms-1">{{ __('Data Modifications') }}</label>
|
||||
<div id="modal-changes-list" class="overflow-hidden border border-light" style="border-radius: 1rem;">
|
||||
<!-- Injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Technical Data --}}
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 px-1">
|
||||
<label class="extra-small text-uppercase fw-black text-secondary mb-0 opacity-50">{{ __('Raw Metadata') }}</label>
|
||||
<button class="btn btn-sm btn-link extra-small text-decoration-none fw-bold p-0" onclick="copyRawJson()">{{ __('COPY JSON') }}</button>
|
||||
</div>
|
||||
<pre id="modal-raw-json" class="bg-dark text-warning p-4 rounded-4 small mb-0 shadow-sm scroll-custom" style="white-space: pre-wrap; font-size: 11px; max-height: 250px; overflow-y: auto; font-family: 'Fira Code', 'JetBrains Mono', monospace; line-height: 1.5;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 p-4 pt-0">
|
||||
<button type="button" class="btn btn-dark w-100 py-3 rounded-pill fw-bold shadow-sm" data-bs-dismiss="modal">{{ __('Close Audit View') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const modal = new bootstrap.Modal('#detailLogModal');
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
// Event category filter handler
|
||||
const filterBtn = e.target.closest(".filter-event");
|
||||
if (filterBtn) {
|
||||
document.querySelectorAll(".filter-event").forEach(b => {
|
||||
b.classList.remove("btn-dark");
|
||||
b.classList.add("btn-outline-dark", "border", "border-dark");
|
||||
});
|
||||
|
||||
filterBtn.classList.add("btn-dark");
|
||||
filterBtn.classList.remove("btn-outline-dark", "border", "border-dark");
|
||||
|
||||
document.getElementById("filter-event-val").value = filterBtn.dataset.value;
|
||||
window.reloadDataTable?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = e.target.closest(".btn-detail-log");
|
||||
if (!btn) return;
|
||||
|
||||
const data = JSON.parse(btn.dataset.activity);
|
||||
|
||||
// Populating data
|
||||
document.getElementById('modal-event-label').textContent = data.event.label;
|
||||
document.getElementById('modal-causer-name').textContent = data.causer.name;
|
||||
document.getElementById('modal-causer-email').textContent = data.causer.email;
|
||||
document.getElementById('modal-module-info').textContent = data.subject.module + " — " + data.subject.type;
|
||||
document.getElementById('modal-time-info').textContent = data.meta.time;
|
||||
document.getElementById('modal-network-info').textContent = "IP: " + data.meta.ip;
|
||||
|
||||
// Build Changes
|
||||
const container = document.getElementById('modal-changes-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (data.changes.length > 0) {
|
||||
data.changes.forEach(change => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'bg-white border-bottom p-3 transition-all hover-bg-light';
|
||||
|
||||
let diffContent = '';
|
||||
if (change.old === 'NULL' && change.new !== 'NULL') {
|
||||
// Creation / Initialization
|
||||
diffContent = `
|
||||
<div class="diff-box bg-success bg-opacity-10 border-success border-opacity-25 p-2 rounded small w-100">
|
||||
<span class="text-success fw-bold me-2">+</span> ${change.new}
|
||||
</div>`;
|
||||
} else if (change.old !== null) {
|
||||
// Update
|
||||
diffContent = `
|
||||
<div class="d-flex flex-column gap-2 w-100">
|
||||
<div class="diff-box bg-danger bg-opacity-10 border-danger border-opacity-25 p-2 rounded small">
|
||||
<span class="text-danger fw-bold me-2">-</span> ${change.old}
|
||||
</div>
|
||||
<div class="diff-box bg-success bg-opacity-10 border-success border-opacity-25 p-2 rounded small">
|
||||
<span class="text-success fw-bold me-2">+</span> ${change.new}
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
// General
|
||||
diffContent = `<div class="p-2 small text-dark fw-medium">${change.new}</div>`;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="row align-items-start g-3">
|
||||
<div class="col-md-3">
|
||||
<span class="extra-small text-uppercase fw-black text-secondary d-block mt-1">${change.field}</span>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
${diffContent}
|
||||
</div>
|
||||
</div>`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div class="p-5 text-center bg-light bg-opacity-50">
|
||||
<i class="bi bi-info-circle text-muted fs-2 mb-2 d-block"></i>
|
||||
<div class="text-muted small fw-bold">No data modifications recorded for this event.</div>
|
||||
<div class="extra-small text-secondary opacity-75">This usually happens for authentication or read events.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.getElementById('modal-raw-json').textContent = JSON.stringify(data.raw, null, 2);
|
||||
modal.show();
|
||||
});
|
||||
|
||||
// Export CSV Handler
|
||||
const exportBtn = document.getElementById('export-csv-btn');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => {
|
||||
const event = document.getElementById('filter-event-val').value;
|
||||
const search = document.querySelector('.dataTables_filter input')?.value || '';
|
||||
|
||||
let url = `{{ route('action-logs.export') }}?event=${event}&search=${encodeURIComponent(search)}`;
|
||||
window.location.href = url;
|
||||
toastr?.info('{{ __("Preparing your export, please wait...") }}');
|
||||
});
|
||||
}
|
||||
|
||||
// Clear Activity Logs Handler
|
||||
const clearBtn = document.getElementById('clear-activity-logs-btn');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => {
|
||||
StandardSwal.fire({
|
||||
title: 'Clear Global Action Logs?',
|
||||
text: 'You are about to permanently erase all recorded system audit trails. This action cannot be undone.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: 'Yes, Clear All',
|
||||
cancelButtonText: 'Cancel'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
fetch('{{ route("action-logs.clear") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: 'success',
|
||||
title: 'Logs Cleared!',
|
||||
text: data.message || 'System audit trails have been successfully removed.',
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
} else {
|
||||
StandardSwal.fire({ icon: 'error', title: 'Action Failed!', text: data.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function copyRawJson() {
|
||||
const content = document.getElementById('modal-raw-json').textContent;
|
||||
navigator.clipboard.writeText(content);
|
||||
toastr?.info('{{ __("Audit JSON Copied to Clipboard") }}');
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.fw-black { font-weight: 900; }
|
||||
.tracking-tight { letter-spacing: -1px; }
|
||||
.extra-small { font-size: 0.65rem; }
|
||||
.hover-bg-light:hover { background-color: #f8fafc !important; }
|
||||
.diff-box { border: 1px solid transparent; font-family: 'Fira Code', 'JetBrains Mono', monospace; word-break: break-all; }
|
||||
.scroll-custom::-webkit-scrollbar { width: 6px; }
|
||||
.scroll-custom::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
|
||||
.bg-dark { background-color: #0f172a !important; }
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,192 @@
|
||||
{{--
|
||||
Two-panel permission picker — single source of truth.
|
||||
ALL items rendered once in Available. Pre-selected ones moved to Assigned by JS on init.
|
||||
Multi-select: click = single, Ctrl+click = toggle, Shift+click = range.
|
||||
--}}
|
||||
@php
|
||||
$preSelected = collect($rolePermIds ?? []);
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.dp-panel {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(72vh - 120px);
|
||||
min-height: 400px;
|
||||
}
|
||||
.dp-panel-head {
|
||||
background: #f8fafc;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-panel-search {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-hint-row {
|
||||
padding: 2px 10px 4px;
|
||||
font-size: 0.6rem;
|
||||
color: #b0b9c8;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-panel-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.dp-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f8fafc;
|
||||
transition: background .1s;
|
||||
user-select: none;
|
||||
}
|
||||
.dp-item:hover { background: #f0f9ff; }
|
||||
.dp-item.selected {
|
||||
background: #dbeafe;
|
||||
outline: 1px solid #93c5fd;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.dp-item-icon { flex-shrink: 0; width: 18px; text-align: center; }
|
||||
.dp-item-name { font-size: 0.78rem; line-height: 1.3; flex: 1; min-width: 0; }
|
||||
.dp-item-cat { font-size: 0.6rem; color: #94a3b8; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dp-item-tab { display: inline-block; font-size: 0.58rem; background: #e2e8f0; color: #475569; padding: 0 4px; border-radius: 3px; font-weight: 600; margin-right: 3px; }
|
||||
.dp-item-type-manage { color: #7c3aed; }
|
||||
.dp-item-type-view { color: #0369a1; }
|
||||
.dp-btn-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-btn {
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all .15s;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dp-btn:hover { background: #111827; color: white; border-color: #111827; }
|
||||
.dp-group-header {
|
||||
padding: 4px 10px 2px;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.dp-empty-hint { padding: 40px 16px; text-align: center; color: #cbd5e1; font-size: 0.78rem; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex align-items-stretch" id="dp-{{ $panelId }}" style="min-width:0;gap:0;">
|
||||
|
||||
{{-- ── LEFT: Available (ALL items live here initially) ──────── --}}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="dp-panel">
|
||||
<div class="dp-panel-head d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold small text-dark">{{ __('Available') }}</span>
|
||||
<span class="badge text-bg-secondary rounded-pill dp-available-count" style="font-size:0.6rem;">0</span>
|
||||
</div>
|
||||
<div class="dp-panel-search">
|
||||
<input type="text" class="form-control form-control-sm dp-search-available border-0 p-0 bg-transparent"
|
||||
placeholder="🔍 {{ __('Filter...') }}" style="font-size:0.78rem;box-shadow:none;">
|
||||
</div>
|
||||
<div class="dp-hint-row">
|
||||
<i class="bi bi-info-circle me-1"></i>Click = select · Ctrl+click = multi · Shift+click = range · Dbl-click = move
|
||||
</div>
|
||||
<div class="dp-panel-body dp-available-list">
|
||||
@foreach ($groupedPermissions as $category => $catPerms)
|
||||
@php
|
||||
$catItems = collect();
|
||||
foreach ($catPerms as $menuName => $menuData) {
|
||||
if ($menuData['manage']) $catItems->push(['perm' => $menuData['manage'], 'menu' => $menuName, 'tab' => null, 'type' => 'manage']);
|
||||
if ($menuData['view']) $catItems->push(['perm' => $menuData['view'], 'menu' => $menuName, 'tab' => null, 'type' => 'view']);
|
||||
foreach ($menuData['tabs'] as $tabSlug => $tabPerms) {
|
||||
if ($tabPerms['manage']) $catItems->push(['perm' => $tabPerms['manage'], 'menu' => $menuName, 'tab' => $tabSlug, 'type' => 'manage']);
|
||||
if ($tabPerms['view']) $catItems->push(['perm' => $tabPerms['view'], 'menu' => $menuName, 'tab' => $tabSlug, 'type' => 'view']);
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
@if($catItems->isNotEmpty())
|
||||
<div class="dp-group-header dp-cat-header" data-cat="{{ Str::slug($category) }}">{{ $category }}</div>
|
||||
@foreach ($catItems as $entry)
|
||||
<div class="dp-item dp-avail-item"
|
||||
data-id="{{ $entry['perm']->id }}"
|
||||
data-name="{{ strtolower($entry['perm']->name) }}"
|
||||
data-cat="{{ Str::slug($category) }}"
|
||||
data-preselected="{{ $preSelected->contains($entry['perm']->id) ? '1' : '0' }}">
|
||||
<span class="dp-item-icon">
|
||||
@if($entry['type'] === 'manage')
|
||||
<i class="bi bi-pencil-square dp-item-type-manage" style="font-size:0.7rem;"></i>
|
||||
@else
|
||||
<i class="bi bi-eye dp-item-type-view" style="font-size:0.7rem;"></i>
|
||||
@endif
|
||||
</span>
|
||||
<span class="dp-item-name">
|
||||
@if($entry['tab'])
|
||||
<span class="dp-item-tab">{{ $entry['tab'] }}</span>
|
||||
@endif
|
||||
{{ $entry['perm']->name }}
|
||||
<span class="dp-item-cat">{{ $category }}</span>
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── CENTER: Buttons ─────────────────────────────────────── --}}
|
||||
<div class="dp-btn-col">
|
||||
<button type="button" class="dp-btn dp-btn-add-selected" title="{{ __('Move selected →') }}"><i class="bi bi-chevron-right"></i></button>
|
||||
<button type="button" class="dp-btn dp-btn-add-all" title="{{ __('Move all →→') }}"><i class="bi bi-chevron-double-right"></i></button>
|
||||
<button type="button" class="dp-btn dp-btn-remove-selected" title="{{ __('← Remove selected') }}"><i class="bi bi-chevron-left"></i></button>
|
||||
<button type="button" class="dp-btn dp-btn-remove-all" title="{{ __('←← Remove all') }}"><i class="bi bi-chevron-double-left"></i></button>
|
||||
</div>
|
||||
|
||||
{{-- ── RIGHT: Assigned (empty on load, filled by JS) ──────── --}}
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="dp-panel">
|
||||
<div class="dp-panel-head d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold small text-dark">{{ __('Assigned') }}</span>
|
||||
<span class="badge rounded-pill dp-assigned-count text-bg-secondary" style="font-size:0.6rem;">0</span>
|
||||
</div>
|
||||
<div class="dp-panel-search">
|
||||
<input type="text" class="form-control form-control-sm dp-search-assigned border-0 p-0 bg-transparent"
|
||||
placeholder="🔍 {{ __('Filter...') }}" style="font-size:0.78rem;box-shadow:none;">
|
||||
</div>
|
||||
<div class="dp-hint-row">
|
||||
<i class="bi bi-info-circle me-1"></i>Dbl-click or select + ◀ to remove
|
||||
</div>
|
||||
<div class="dp-panel-body dp-assigned-list">
|
||||
<div class="dp-empty-hint">
|
||||
<i class="bi bi-arrow-left-circle d-block mb-2" style="font-size:1.8rem;opacity:0.2;"></i>
|
||||
{{ __('No permissions assigned yet') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,206 @@
|
||||
{{--
|
||||
Permission matrix partial — tree view with collapsible tabs.
|
||||
Variables:
|
||||
$groupedPermissions — tree from RoleManagementController::groupPermissions()
|
||||
$idPrefix — 'add' | 'edit'
|
||||
$rolePermIds — array of pre-selected permission IDs (empty for add)
|
||||
--}}
|
||||
@foreach ($groupedPermissions as $category => $perms)
|
||||
@php
|
||||
$catSlug = Str::slug($category);
|
||||
$allInCat = collect($perms)->flatMap(function ($m) {
|
||||
$ids = [];
|
||||
if ($m['manage']) $ids[] = $m['manage']->id;
|
||||
if ($m['view']) $ids[] = $m['view']->id;
|
||||
foreach ($m['tabs'] as $t) {
|
||||
if ($t['manage']) $ids[] = $t['manage']->id;
|
||||
if ($t['view']) $ids[] = $t['view']->id;
|
||||
}
|
||||
return $ids;
|
||||
})->values();
|
||||
$totalInCat = $allInCat->count();
|
||||
@endphp
|
||||
|
||||
<div class="perm-category-group mb-2"
|
||||
data-total="{{ $totalInCat }}"
|
||||
data-cat="{{ $catSlug }}">
|
||||
|
||||
{{-- ── Category header ── --}}
|
||||
<div class="d-flex justify-content-between align-items-center px-2 py-1 rounded-2 mb-1"
|
||||
style="background:#f1f5f9; border-left:3px solid #64748b;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="fw-bold small text-dark">{{ $category }}</span>
|
||||
<span class="perm-badge badge rounded-pill text-bg-secondary" style="font-size:0.6rem;">
|
||||
0 / {{ $totalInCat }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="form-check form-check-inline mb-0">
|
||||
<input class="form-check-input select-all-category" type="checkbox"
|
||||
id="{{ $idPrefix }}-all-{{ $catSlug }}"
|
||||
title="{{ __('Select all in this category') }}">
|
||||
<label class="form-check-label small text-muted cursor-pointer"
|
||||
for="{{ $idPrefix }}-all-{{ $catSlug }}">
|
||||
{{ __('All') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Column headers ── --}}
|
||||
<div class="row g-0 px-1 mb-1 text-uppercase text-secondary"
|
||||
style="font-size:0.6rem;font-weight:800;letter-spacing:0.4px;">
|
||||
<div class="col-6 ps-1">{{ __('Manage') }}</div>
|
||||
<div class="col-6 ps-3">{{ __('View') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-1">
|
||||
@foreach ($perms as $menuName => $menuData)
|
||||
@php
|
||||
$hasTabs = ! empty($menuData['tabs']);
|
||||
$menuSlug = Str::slug($menuName);
|
||||
$collapseId = $idPrefix . '-tabs-' . $catSlug . '-' . $menuSlug;
|
||||
$tabCount = count($menuData['tabs']);
|
||||
@endphp
|
||||
|
||||
<div class="perm-menu-row mb-1" data-base="{{ strtolower($menuName) }}">
|
||||
|
||||
{{-- ── Menu-level row ── --}}
|
||||
<div class="row g-0 align-items-center permission-pair-row"
|
||||
data-base="{{ strtolower($menuName) }}">
|
||||
|
||||
{{-- Manage column --}}
|
||||
<div class="col-6 text-break pe-2">
|
||||
@if($menuData['manage'])
|
||||
@php $p = $menuData['manage']; @endphp
|
||||
<div class="permission-item d-flex align-items-center gap-1"
|
||||
data-name="{{ strtolower($p->name) }}">
|
||||
@if($hasTabs)
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm p-0 lh-1 text-secondary tab-collapse-toggle flex-shrink-0"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#{{ $collapseId }}"
|
||||
aria-expanded="false"
|
||||
title="{{ $tabCount }} tab permissions">
|
||||
<i class="bi bi-chevron-right perm-chevron" style="font-size:0.65rem;transition:transform .2s;"></i>
|
||||
</button>
|
||||
@else
|
||||
<span style="width:14px;display:inline-block;"></span>
|
||||
@endif
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-manage"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
data-manage-for="{{ $idPrefix }}-perm-{{ $menuData['view']?->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer fw-semibold lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
{{ $p->name }}
|
||||
@if($hasTabs)
|
||||
<span class="text-muted fw-normal" style="font-size:0.6rem;">(+{{ $tabCount }} tabs)</span>
|
||||
@endif
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- View column --}}
|
||||
<div class="col-6 ps-3 text-break">
|
||||
@if($menuData['view'])
|
||||
@php $p = $menuData['view']; @endphp
|
||||
<div class="permission-item" data-name="{{ strtolower($p->name) }}">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-view"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer fw-semibold lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
{{ $p->name }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Collapsible tab rows ── --}}
|
||||
@if($hasTabs)
|
||||
<div class="collapse" id="{{ $collapseId }}">
|
||||
<div class="ms-4 mt-1 ps-2 border-start border-2"
|
||||
style="border-color:#cbd5e1 !important;">
|
||||
<div class="row g-0 mb-1 text-uppercase text-secondary"
|
||||
style="font-size:0.58rem;font-weight:800;letter-spacing:0.3px;">
|
||||
<div class="col-6">{{ __('Manage Tab') }}</div>
|
||||
<div class="col-6 ps-3">{{ __('View Tab') }}</div>
|
||||
</div>
|
||||
@foreach ($menuData['tabs'] as $tabSlug => $tabPerms)
|
||||
<div class="row g-0 align-items-center mb-1 permission-pair-row"
|
||||
data-base="{{ strtolower($menuName . ':' . $tabSlug) }}">
|
||||
|
||||
{{-- Manage tab --}}
|
||||
<div class="col-6 text-break pe-2">
|
||||
@if($tabPerms['manage'])
|
||||
@php $p = $tabPerms['manage']; @endphp
|
||||
<div class="permission-item" data-name="{{ strtolower($p->name) }}">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-manage"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
data-manage-for="{{ $idPrefix }}-perm-{{ $tabPerms['view']?->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
<span class="badge me-1"
|
||||
style="font-size:0.55rem;background:#e2e8f0;color:#475569;font-weight:600;">
|
||||
{{ $tabSlug }}
|
||||
</span>
|
||||
manage
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- View tab --}}
|
||||
<div class="col-6 ps-3 text-break">
|
||||
@if($tabPerms['view'])
|
||||
@php $p = $tabPerms['view']; @endphp
|
||||
<div class="permission-item" data-name="{{ strtolower($p->name) }}">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input perm-checkbox perm-view"
|
||||
type="checkbox" name="permissions[]"
|
||||
value="{{ $p->id }}"
|
||||
id="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
@if(in_array($p->id, $rolePermIds ?? [])) checked @endif>
|
||||
<label class="form-check-label small cursor-pointer lh-sm"
|
||||
for="{{ $idPrefix }}-perm-{{ $p->id }}"
|
||||
title="{{ $p->name }}">
|
||||
<span class="badge me-1"
|
||||
style="font-size:0.55rem;background:#e2e8f0;color:#475569;font-weight:600;">
|
||||
{{ $tabSlug }}
|
||||
</span>
|
||||
view
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@@ -0,0 +1,382 @@
|
||||
<x-app-layout>
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- permission management page header --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('Permission Management') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Manage permissions, define access rules, and control user capabilities within the system.') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<a href="{{ route('roles') }}#rbac-docs"
|
||||
class="btn btn-outline-dark px-3 rounded-pill d-flex align-items-center gap-2">
|
||||
<i class="bi bi-book"></i> {{ __('Documentation') }}
|
||||
</a>
|
||||
@can('manage access rights')
|
||||
<button class="btn btn-primary px-3" data-bs-toggle="modal"
|
||||
data-bs-target="#addPermissionModal">
|
||||
 + {{ __('Add Permission') }} 
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Quick reference banner --}}
|
||||
<div class="d-flex align-items-start gap-3 p-3 rounded-4 mb-3" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<i class="bi bi-info-circle-fill text-primary fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.88rem;">{{ __('Permissions are the atomic building blocks') }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">
|
||||
{{ __('Each permission represents ONE specific action (e.g.') }} <code>view dashboard</code>,
|
||||
<code>manage users</code>). {{ __('You assign permissions to') }} <span class="fw-bold">{{ __('roles') }}</span>,
|
||||
{{ __('then assign roles to') }} <span class="fw-bold">{{ __('users') }}</span>.
|
||||
<a href="{{ route('roles') }}#rbac-docs" class="text-primary fw-bold text-decoration-none">{{ __('Read the full RBAC guide →') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- permissions table --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive overflow-hidden">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('permissions') }}"
|
||||
data-order='@json([[4, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-wrap">{{ __('Status') }}</th>
|
||||
<th class="text-wrap">{{ __('Permission Name') }}</th>
|
||||
<th class="text-wrap">{{ __('Module / Guard') }}</th>
|
||||
<th class="text-wrap">{{ __('Assigned To (Roles)') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created By') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated By') }}</th>
|
||||
@can('manage access rights')
|
||||
<th class="text-end text-wrap" data-orderable="false"
|
||||
data-searchable="false">{{ __('Action') }}</th>
|
||||
@endcan
|
||||
</tr>
|
||||
|
||||
{{-- filter bar --}}
|
||||
<tr class="filter-row">
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="active">{{ __('Active') }}</option>
|
||||
<option value="inactive">{{ __('Inactive') }}</option>
|
||||
</select>
|
||||
</th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Permission Name') }}"></th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="web">web</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
</th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Assigned Roles') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search User') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search User') }}"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- add permission modal --}}
|
||||
<div class="modal fade" id="addPermissionModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Add Permission') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('permissions.store') }}" autocomplete="off" class="ajax-form" data-reset="true">
|
||||
|
||||
@csrf
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{{-- Permission Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Permission Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="form-control mb-3"
|
||||
placeholder="ex: view_report / edit_data" required minlength="3"
|
||||
maxlength="100" pattern="^[a-zA-Z0-9_\-\.\/]+$"
|
||||
title="Minimum 3 characters. Allowed: letters, numbers, dash, underscore, dot, and slash">
|
||||
|
||||
{{-- Module / Guard --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Module / Guard') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select name="guard_name" class="form-select mb-3" required
|
||||
title="Select guard for this permission">
|
||||
<option value="web" selected>web</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill">
|
||||
 Save 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- edit permission modal --}}
|
||||
<div class="modal fade" id="editPermissionModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Edit Permission') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editPermissionForm" method="POST" autocomplete="off" class="ajax-form">
|
||||
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{{-- hidden id --}}
|
||||
<input type="hidden" id="edit-permission-id" name="id">
|
||||
|
||||
{{-- Permission Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Permission Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input id="edit-permission-name" name="name" type="text"
|
||||
class="form-control mb-3" placeholder="ex: view_report / edit_data" required
|
||||
minlength="3" maxlength="100" pattern="^[a-zA-Z0-9_\-\.\/]+$"
|
||||
title="Minimum 3 characters. Allowed: letters, numbers, dash, underscore, dot, and slash">
|
||||
|
||||
{{-- Module / Guard --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Module / Guard') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select id="edit-permission-guard" name="guard_name" class="form-select mb-3"
|
||||
required title="Select guard for this permission">
|
||||
<option value="web">web</option>
|
||||
<option value="api">api</option>
|
||||
</select>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary rounded-pill">
|
||||
 Update 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- script handler (status, delete, edit data fill) --}}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// =========================
|
||||
// TOGGLE STATUS (SERVER-DRIVEN)
|
||||
// =========================
|
||||
document.addEventListener("change", e => {
|
||||
const toggle = e.target.closest(".permission-toggle");
|
||||
|
||||
if (!toggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toggle.dataset.id;
|
||||
const name = toggle.dataset.name;
|
||||
const status = toggle.checked ? "activate" : "deactivate";
|
||||
|
||||
StandardSwal.fire({
|
||||
title: `${status === "activate" ? "Activate" : "Deactivate"} Permission?`,
|
||||
text: `You are about to change the system access rights for "${name}".`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, Continue",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) {
|
||||
toggle.checked = !toggle.checked;
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("{{ route('permissions.toggle-status') }}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector(
|
||||
'meta[name="csrf-token"]').content,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
status
|
||||
}),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Live Reload immediately
|
||||
window.reloadDataTable?.();
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Success!') }}",
|
||||
text: data.message || "{{ __('The permission status has been updated successfully.') }}",
|
||||
icon: "success",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Error!') }}",
|
||||
text: "{{ __('A server error occurred while updating the permission.') }}",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =========================
|
||||
// FILL EDIT MODAL
|
||||
// =========================
|
||||
const form = document.getElementById("editPermissionForm");
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
const editButton = e.target.closest(".btn-edit");
|
||||
|
||||
if (editButton) {
|
||||
const updateRoute =
|
||||
`{{ route('permissions.update', 'PERMISSION_ID') }}`
|
||||
.replace("PERMISSION_ID", editButton.dataset.id);
|
||||
|
||||
form.action = updateRoute;
|
||||
|
||||
document.getElementById("edit-permission-id").value = editButton
|
||||
.dataset.id ?? "";
|
||||
document.getElementById("edit-permission-name").value = editButton
|
||||
.dataset.name ?? "";
|
||||
document.getElementById("edit-permission-guard").value = editButton
|
||||
.dataset.guard ?? "";
|
||||
return;
|
||||
}
|
||||
|
||||
// =========================
|
||||
// DELETE / ARCHIVE
|
||||
// =========================
|
||||
const deleteButton = e.target.closest(".btn-delete");
|
||||
|
||||
if (!deleteButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = deleteButton.dataset.id;
|
||||
const name = deleteButton.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "Archive Permission?",
|
||||
text: `"${name}" will be deactivated and moved to global archives.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Archive",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = "{{ route('permissions.destroy', 'ID') }}".replace("ID", id);
|
||||
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector(
|
||||
'meta[name="csrf-token"]').content,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Live Reload immediately
|
||||
window.reloadDataTable?.();
|
||||
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "Archived Successfully!",
|
||||
text: data.message || "The permission has been archived.",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
StandardSwal.fire({
|
||||
title: "Error!",
|
||||
text: "An error occurred during the archive process.",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,711 @@
|
||||
<x-app-layout>
|
||||
@php
|
||||
$canManageUsers = auth()->user()->can('manage user directory');
|
||||
$userOrderIndex = $canManageUsers ? 5 : 3;
|
||||
@endphp
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- header halaman user management --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('User Management') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Manage users, assign roles, and control access within the system.') }}
|
||||
</small>
|
||||
{{-- Extra Filters (Picked up by app.blade.php DataTable logic) --}}
|
||||
<input type="hidden" id="filter-trashed-val" name="trashed" class="filter-extra"
|
||||
value="active">
|
||||
</div>
|
||||
@can('manage user directory')
|
||||
<button class="btn btn-dark mt-3 px-3 rounded-pill" data-bs-toggle="modal"
|
||||
data-bs-target="#addUserModal">
|
||||
 + {{ __('Add User') }} 
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-dark rounded-pill px-3 filter-trashed"
|
||||
data-value="active">
|
||||
<i class="bi bi-people me-1"></i> {{ __('Active Users') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-dark border border-dark rounded-pill px-3 filter-trashed"
|
||||
data-value="archived">
|
||||
<i class="bi bi-archive me-1"></i> {{ __('Archived') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- tabel users --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive overflow-hidden">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('users') }}"
|
||||
data-order='@json([[$userOrderIndex, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
@can('manage user directory')
|
||||
<th style="width: 20px;" data-orderable="false" data-searchable="false">
|
||||
<input type="checkbox" class="form-check-input check-all">
|
||||
</th>
|
||||
<th class="text-wrap">{{ __('Status') }}</th>
|
||||
@endcan
|
||||
<th class="text-wrap">{{ __('User Name') }}</th>
|
||||
<th class="text-wrap">{{ __('Email') }}</th>
|
||||
<th class="text-wrap">{{ __('Roles') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Created By') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated At') }}</th>
|
||||
<th class="text-wrap" data-hide="audit">{{ __('Last Updated By') }}</th>
|
||||
@can('manage user directory')
|
||||
<th class="text-wrap text-end" data-orderable="false"
|
||||
data-searchable="false">{{ __('Action') }}</th>
|
||||
@endcan
|
||||
</tr>
|
||||
|
||||
{{-- filter --}}
|
||||
<tr class="filter-row">
|
||||
@can('manage user directory')
|
||||
<th></th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="active">{{ __('Active') }}</option>
|
||||
<option value="inactive">{{ __('Inactive') }}</option>
|
||||
</select>
|
||||
</th>
|
||||
@endcan
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Name') }}"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Email') }}"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Search Role') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Created By') }}"></th>
|
||||
<th><input type="date" class="form-control form-control-sm"></th>
|
||||
<th><input class="form-control form-control-sm"
|
||||
placeholder="{{ __('Updated By') }}"></th>
|
||||
@can('manage user directory')
|
||||
<th></th>
|
||||
@endcan
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL ADD USER --}}
|
||||
<div class="modal fade" id="addUserModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Add User') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('users.store') }}" autocomplete="off"
|
||||
class="ajax-form" data-reset="true">
|
||||
|
||||
@csrf
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
{{-- User Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('User Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="form-control mb-3"
|
||||
placeholder="ex: John Wick" required minlength="3" maxlength="100"
|
||||
pattern="^[a-zA-Z\s]+$"
|
||||
title="Name must be at least 3 characters and contain letters and spaces only">
|
||||
|
||||
{{-- Email --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Email') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="email" name="email" class="form-control mb-3"
|
||||
placeholder="john@email.com" required maxlength="150"
|
||||
title="Enter a valid and unique email address">
|
||||
|
||||
{{-- Password --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Password') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group mb-3">
|
||||
<input type="password" name="password" class="form-control border-end-0"
|
||||
placeholder="Minimum 12 characters" required minlength="12"
|
||||
autocomplete="new-password"
|
||||
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{12,}$"
|
||||
title="Minimum 12 characters with uppercase, lowercase, number, and symbol">
|
||||
<button
|
||||
class="btn btn-outline-secondary bg-white border-start-0 password-toggle"
|
||||
type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Assign Role --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Assign Role') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select id="roleSelect" name="roles[]" class="form-select" multiple required
|
||||
title="Select at least one role">
|
||||
@foreach ($roles as $role)
|
||||
@if($role->name !== 'Developer' || auth()->user()->hasRole('Developer'))
|
||||
<option value="{{ $role->id }}">{{ $role->name }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill">
|
||||
 Save User 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL EDIT USER --}}
|
||||
<div class="modal fade" id="editUserModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Edit User') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editUserForm" method="POST" autocomplete="off" class="ajax-form">
|
||||
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<input type="hidden" id="edit-user-id" name="id">
|
||||
|
||||
{{-- User Name --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('User Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input id="edit-user-name" name="name" type="text" class="form-control mb-3"
|
||||
placeholder="ex: John Wick" required minlength="3" maxlength="100"
|
||||
pattern="^[a-zA-Z\s]+$"
|
||||
title="Name must be at least 3 characters and contain letters and spaces only">
|
||||
|
||||
{{-- Email --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Email') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input id="edit-user-email" name="email" type="email"
|
||||
class="form-control mb-3" placeholder="john@email.com" required
|
||||
maxlength="150" title="Enter a valid and unique email address">
|
||||
|
||||
{{-- Password (Optional) --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Password (Optional)') }}
|
||||
</label>
|
||||
<div class="input-group mb-3">
|
||||
<input name="password" type="password" class="form-control border-end-0"
|
||||
placeholder="leave empty to keep current" minlength="12"
|
||||
autocomplete="new-password"
|
||||
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{12,}$"
|
||||
title="Minimum 12 characters with uppercase, lowercase, number, and symbol">
|
||||
<button
|
||||
class="btn btn-outline-secondary bg-white border-start-0 password-toggle"
|
||||
type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{-- Roles --}}
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Roles') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<select id="roleSelect2" name="roles[]" class="form-select" multiple
|
||||
required title="Select at least one role">
|
||||
@foreach ($roles as $role)
|
||||
@if($role->name !== 'Developer' || auth()->user()->hasRole('Developer'))
|
||||
<option value="{{ $role->id }}">{{ $role->name }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill"
|
||||
data-bs-dismiss="modal">
|
||||
 Close 
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill">
|
||||
 Update User 
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SCRIPT HANDLER --}}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// init choices create
|
||||
const roleSelect = document.querySelector('#roleSelect');
|
||||
if (roleSelect && !roleSelect.dataset.choices) {
|
||||
new Choices(roleSelect, {
|
||||
removeItemButton: true,
|
||||
searchEnabled: true,
|
||||
placeholderValue: 'Select roles'
|
||||
});
|
||||
roleSelect.dataset.choices = "initialized";
|
||||
}
|
||||
|
||||
// init choices edit
|
||||
const roleSelect2 = document.querySelector('#roleSelect2');
|
||||
let editChoices = null;
|
||||
if (roleSelect2 && !roleSelect2.dataset.choices) {
|
||||
editChoices = new Choices(roleSelect2, {
|
||||
removeItemButton: true,
|
||||
searchEnabled: true,
|
||||
placeholderValue: 'Select roles'
|
||||
});
|
||||
roleSelect2.dataset.choices = "initialized";
|
||||
}
|
||||
|
||||
document.addEventListener("change", e => {
|
||||
const toggle = e.target.closest(".user-toggle");
|
||||
|
||||
if (!toggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = toggle.dataset.id;
|
||||
const name = toggle.dataset.name;
|
||||
const status = toggle.checked ? "activate" : "deactivate";
|
||||
const label = toggle.closest(".form-switch").querySelector(".status-label");
|
||||
|
||||
StandardSwal.fire({
|
||||
title: `${status === "activate" ? "Activate" : "Deactivate"} User?`,
|
||||
text: `You are about to change the access status for "${name}".`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, Continue",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) {
|
||||
toggle.checked = !toggle.checked;
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = toggle.checked ? "Active" : "Inactive";
|
||||
label.classList.toggle("text-success", toggle.checked);
|
||||
label.classList.toggle("text-danger", !toggle.checked);
|
||||
|
||||
fetch("{{ route('users.toggle-status') }}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
status
|
||||
}),
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
throw new Error("Failed to update status.");
|
||||
}
|
||||
|
||||
// Live Reload immediately
|
||||
window.reloadDataTable?.();
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Success!') }}",
|
||||
text: data.message || "{{ __('User status has been updated successfully.') }}",
|
||||
icon: "success",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toggle.checked = !toggle.checked;
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Error!') }}",
|
||||
text: "{{ __('An unexpected error occurred while updating status.') }}",
|
||||
icon: "error"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("click", e => {
|
||||
// Filter Active/Archived
|
||||
const filterBtn = e.target.closest(".filter-trashed");
|
||||
if (filterBtn) {
|
||||
document.querySelectorAll(".filter-trashed").forEach(b => {
|
||||
b.classList.remove("btn-dark");
|
||||
b.classList.add("btn-outline-dark", "border", "border-dark");
|
||||
});
|
||||
filterBtn.classList.add("btn-dark");
|
||||
filterBtn.classList.remove("btn-outline-dark", "border", "border-dark");
|
||||
|
||||
document.getElementById("filter-trashed-val").value = filterBtn.dataset.value;
|
||||
window.reloadDataTable?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const editButton = e.target.closest(".btn-edit");
|
||||
|
||||
if (editButton) {
|
||||
const url = `{{ route('users.update', 'ID') }}`.replace("ID",
|
||||
editButton.dataset.id);
|
||||
document.getElementById("editUserForm").action = url;
|
||||
|
||||
document.getElementById("edit-user-id").value = editButton.dataset.id;
|
||||
document.getElementById("edit-user-name").value = editButton.dataset
|
||||
.name;
|
||||
document.getElementById("edit-user-email").value = editButton.dataset
|
||||
.email;
|
||||
|
||||
const selected = JSON.parse(editButton.dataset.roles || "[]");
|
||||
if (editChoices) {
|
||||
editChoices.removeActiveItems();
|
||||
selected.forEach(role => editChoices.setChoiceByValue(role
|
||||
.toString()));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteButton = e.target.closest(".btn-delete");
|
||||
|
||||
if (deleteButton) {
|
||||
e.preventDefault();
|
||||
const id = deleteButton.dataset.id;
|
||||
const name = deleteButton.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "Archive User?",
|
||||
text: `"${name}" will be deactivated and moved to the system archives.`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Archive",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = `{{ route('users.destroy', 'ID') }}`.replace("ID", id);
|
||||
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector(
|
||||
'meta[name="csrf-token"]').content,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "Archived Successfully!",
|
||||
text: data.message || "The user has been moved to the archived list.",
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// RESTORE HANDLER
|
||||
const restoreBtn = e.target.closest(".btn-restore");
|
||||
if (restoreBtn) {
|
||||
e.preventDefault();
|
||||
const id = restoreBtn.dataset.id;
|
||||
const name = restoreBtn.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Restore User?') }}",
|
||||
text: `{{ __('Do you want to restore access for') }} "${name}"?`,
|
||||
icon: "info",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('Yes, Restore') }}",
|
||||
cancelButtonText: "{{ __('Cancel') }}",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = `{{ route('users.restore', 'ID') }}`.replace("ID", id);
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "{{ __('Restored!') }}",
|
||||
text: data.message,
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// FORCE DELETE HANDLER
|
||||
const forceBtn = e.target.closest(".btn-force-delete");
|
||||
if (forceBtn) {
|
||||
e.preventDefault();
|
||||
const id = forceBtn.dataset.id;
|
||||
const name = forceBtn.dataset.name;
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Terminate Account?') }}",
|
||||
text: `{{ __('This will PERMANENTLY delete') }} "${name}". {{ __('This action cannot be undone.') }}`,
|
||||
icon: "error",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('Yes, Terminate') }}",
|
||||
cancelButtonText: "{{ __('Cancel') }}",
|
||||
confirmButtonColor: "#dc3545",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
const url = `{{ route('users.force-delete', 'ID') }}`.replace("ID", id);
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
StandardSwal.fire({
|
||||
icon: "success",
|
||||
title: "{{ __('Terminated!') }}",
|
||||
text: data.message,
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
timerProgressBar: true
|
||||
});
|
||||
}).catch(err => {
|
||||
StandardSwal.fire("Error", "Failed to terminate user.", "error");
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// BULK ACTION LOGIC
|
||||
const bulkBtn = e.target.closest(".bulk-btn");
|
||||
if (bulkBtn) {
|
||||
e.preventDefault();
|
||||
const action = bulkBtn.dataset.action;
|
||||
const selectedIds = Array.from(document.querySelectorAll(".user-checkbox:checked")).map(cb => cb.value);
|
||||
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
let config = {
|
||||
title: "Are you sure?",
|
||||
text: `You are about to perform ${action} on ${selectedIds.length} users.`,
|
||||
icon: "warning",
|
||||
url: "",
|
||||
method: "POST",
|
||||
body: { ids: selectedIds }
|
||||
};
|
||||
|
||||
switch(action) {
|
||||
case 'activate':
|
||||
config.url = "{{ route('users.bulk-toggle-status') }}";
|
||||
config.body.status = 'activate';
|
||||
break;
|
||||
case 'deactivate':
|
||||
config.url = "{{ route('users.bulk-toggle-status') }}";
|
||||
config.body.status = 'deactivate';
|
||||
break;
|
||||
case 'archive':
|
||||
config.url = "{{ route('users.bulk-delete') }}";
|
||||
config.icon = "error";
|
||||
break;
|
||||
case 'restore':
|
||||
config.url = "{{ route('users.bulk-restore') }}";
|
||||
config.icon = "info";
|
||||
break;
|
||||
case 'terminate':
|
||||
config.url = "{{ route('users.bulk-force-delete') }}";
|
||||
config.icon = "error";
|
||||
config.text += " THIS ACTION IS PERMANENT!";
|
||||
break;
|
||||
}
|
||||
|
||||
StandardSwal.fire({
|
||||
title: config.title,
|
||||
text: config.text,
|
||||
icon: config.icon,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Yes, Proceed",
|
||||
}).then(result => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
fetch(config.url, {
|
||||
method: config.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": "{{ csrf_token() }}",
|
||||
"Accept": "application/json"
|
||||
},
|
||||
body: JSON.stringify(config.body)
|
||||
}).then(res => res.json())
|
||||
.then(data => {
|
||||
window.reloadDataTable?.();
|
||||
document.querySelector(".check-all").checked = false;
|
||||
StandardSwal.fire("Success", data.message, "success");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check All Handler
|
||||
document.addEventListener("change", e => {
|
||||
if (e.target.classList.contains("check-all")) {
|
||||
document.querySelectorAll(".user-checkbox").forEach(cb => cb.checked = e.target.checked);
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
if (e.target.classList.contains("user-checkbox")) {
|
||||
updateBulkBar();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("clear-selection")?.addEventListener("click", () => {
|
||||
document.querySelectorAll(".user-checkbox, .check-all").forEach(cb => cb.checked = false);
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
function updateBulkBar() {
|
||||
const checked = document.querySelectorAll(".user-checkbox:checked");
|
||||
const bar = document.getElementById("bulk-action-bar");
|
||||
const count = document.getElementById("selected-count");
|
||||
const filterEl = document.getElementById("filter-trashed-val");
|
||||
|
||||
if (!bar || !count) return;
|
||||
|
||||
const filterType = filterEl ? filterEl.value : 'active';
|
||||
|
||||
if (checked.length > 0) {
|
||||
bar.classList.remove("d-none");
|
||||
count.textContent = checked.length;
|
||||
|
||||
// Toggle visibility of specific bulk actions based on active/archived view
|
||||
if (filterType === 'archived') {
|
||||
document.querySelectorAll(".show-if-active").forEach(el => el.classList.add("d-none"));
|
||||
document.querySelectorAll(".show-if-archived").forEach(el => el.classList.remove("d-none"));
|
||||
} else {
|
||||
document.querySelectorAll(".show-if-active").forEach(el => el.classList.remove("d-none"));
|
||||
document.querySelectorAll(".show-if-archived").forEach(el => el.classList.add("d-none"));
|
||||
}
|
||||
} else {
|
||||
bar.classList.add("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
// Reset selection on dataTable reload
|
||||
window.addEventListener('dataTableReloaded', () => {
|
||||
document.querySelector(".check-all").checked = false;
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Floating Bulk Action Bar --}}
|
||||
<div id="bulk-action-bar" class="d-none animate__animated animate__fadeIn position-fixed bottom-0 mb-4 start-50 translate-middle-x" style="z-index: 2500;">
|
||||
<div class="bg-dark text-white border-0 rounded-pill px-3 py-2 d-flex align-items-center gap-3 shadow-lg" style="backdrop-filter: blur(15px); background-color: rgba(20, 20, 20, 0.98) !important; border: 1px solid rgba(255,255,255,0.1) !important;">
|
||||
<div class="d-flex align-items-center gap-2 ps-2">
|
||||
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center" style="width: 28px; height: 28px;">
|
||||
<span class="small fw-bold text-white" id="selected-count">0</span>
|
||||
</div>
|
||||
<span class="small fw-bold d-none d-sm-inline">{{ __('Terpilih') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="vr bg-white opacity-25" style="height: 20px;"></div>
|
||||
|
||||
<div class="d-flex gap-1">
|
||||
{{-- Quick Actions --}}
|
||||
<button title="{{ __('Aktifkan') }}" class="btn btn-link text-success p-2 bulk-btn" data-action="activate">
|
||||
<i class="bi bi-check-circle fs-5"></i>
|
||||
</button>
|
||||
<button title="{{ __('Nonaktifkan') }}" class="btn btn-link text-white p-2 bulk-btn" data-action="deactivate">
|
||||
<i class="bi bi-dash-circle fs-5"></i>
|
||||
</button>
|
||||
|
||||
<div class="vr bg-white opacity-25 mx-1" style="height: 20px; margin-top: 10px;"></div>
|
||||
|
||||
{{-- Contextual Actions --}}
|
||||
<button title="{{ __('Arsipkan') }}" class="btn btn-link p-2 bulk-btn show-if-active" data-action="archive">
|
||||
<i class="bi bi-trash fs-5 text-danger" style="color: #dc3545 !important;"></i>
|
||||
</button>
|
||||
<button title="{{ __('Pulihkan') }}" class="btn btn-link text-info p-2 bulk-btn show-if-archived d-none" data-action="restore">
|
||||
<i class="bi bi-arrow-counterclockwise fs-5"></i>
|
||||
</button>
|
||||
<button title="{{ __('Hapus Permanen') }}" class="btn btn-link text-danger p-2 bulk-btn show-if-archived d-none" data-action="terminate">
|
||||
<i class="bi bi-x-circle fs-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vr bg-white opacity-25" style="height: 20px;"></div>
|
||||
|
||||
<button type="button" class="btn-close btn-close-white btn-sm me-2" id="clear-selection" style="font-size: 0.6rem;"></button>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,583 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.sparkline-container {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.card-body { position: relative; z-index: 1; }
|
||||
|
||||
/* Skeleton */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e6e6e6 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 4px; display: inline-block;
|
||||
}
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.skeleton-text { height: 1rem; margin-bottom: .5rem; width: 100%; }
|
||||
.skeleton-title { height: 2.5rem; width: 60%; margin: 10px auto; }
|
||||
|
||||
/* Widget grid */
|
||||
.widget-col { transition: opacity .2s, transform .2s; }
|
||||
.widget-col.hidden-widget { display: none !important; }
|
||||
.widget-col.widget-ghost { opacity: .3; }
|
||||
.widget-col.widget-chosen { transform: scale(1.02); box-shadow: 0 10px 30px rgba(0,0,0,.15) !important; z-index: 10; }
|
||||
|
||||
/* Customize panel */
|
||||
#widget-customize-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
.widget-toggle-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 12px; border-radius: 10px; cursor: pointer;
|
||||
border: 1px solid #f1f5f9; margin-bottom: 6px;
|
||||
transition: background .15s;
|
||||
}
|
||||
.widget-toggle-item:hover { background: #f8fafc; }
|
||||
.widget-toggle-item.active { border-color: #bfdbfe; background: #eff6ff; }
|
||||
|
||||
/* Live pulse dot */
|
||||
.live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #22c55e; display: inline-block;
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: .5; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
.fw-black { font-weight: 900; }
|
||||
.tracking-tight { letter-spacing: -2px; }
|
||||
.display-3 { font-size: 3.5rem; letter-spacing: -2px; }
|
||||
@media (max-width: 1400px) { .display-3 { font-size: 2.8rem; } }
|
||||
@media (max-width: 576px) { .display-3 { font-size: 2.2rem; } }
|
||||
.extra-small { font-size: .85rem !important; }
|
||||
.mini-progress { height: 4px; background: #f1f5f9; border-radius: 2px; overflow: hidden; }
|
||||
.mini-progress .bar { height: 100%; background: var(--adminuiux-theme-1); border-radius: 2px; transition: width .5s; }
|
||||
.bi-spin { animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.hover-lift { transition: transform .2s, box-shadow .2s; }
|
||||
.hover-lift:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.1) !important; }
|
||||
|
||||
/* ── Terminal Modal ───────────────────────────────────── */
|
||||
@keyframes blink-cursor {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
#logDetailModal .modal-content {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
#logDetailModal .modal-content::-webkit-scrollbar { width: 6px; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar { width: 5px; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar-track { background: #0d1117; }
|
||||
#logDetailModal .p-4::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
|
||||
#terminal-output {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-5" id="monitoring-master">
|
||||
|
||||
{{-- ── Welcome Header ───────────────────────────────── --}}
|
||||
<div class="card adminuiux-card bg-dark text-white mb-4 border-0 shadow-lg overflow-hidden animate__animated animate__fadeIn">
|
||||
<div class="card-body p-4 position-relative">
|
||||
<div class="row align-items-center position-relative z-1">
|
||||
<div class="col">
|
||||
<h1 class="display-5 fw-bold text-white mb-1 tracking-tight">{{ __('Operational Dashboard') }}</h1>
|
||||
<p class="small text-white-50 mb-0 d-flex align-items-center gap-2">
|
||||
<span class="live-dot"></span>
|
||||
System operational since
|
||||
<span class="badge text-bg-theme-1 rounded-pill px-3 shadow-sm" id="stat-uptime-badge">{{ $stats['uptime'] }}</span>
|
||||
at <span class="fw-bold">{{ $stats['hostname'] }}</span> ({{ $stats['ip'] }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<button class="btn btn-outline-light btn-sm rounded-pill px-3 fw-semibold small" id="btn-customize-widgets">
|
||||
<i class="bi bi-grid me-1"></i> Customize
|
||||
</button>
|
||||
<button class="btn btn-theme-1 btn-square rounded-circle shadow-sm" id="refresh-all-stats" title="Refresh">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-decoration"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Customize Panel ──────────────────────────────── --}}
|
||||
<div id="widget-customize-panel" class="shadow-sm border-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0"><i class="bi bi-sliders me-2 text-theme-1"></i>Customize Widgets</h6>
|
||||
<p class="text-muted small mb-0 mt-1">Toggle widgets on/off. Drag cards to reorder your workspace.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-pill px-3" id="btn-toggle-all-widgets">Toggle All</button>
|
||||
<form action="{{ route('dashboard.widgets.reset') }}" method="POST" class="d-inline" id="form-reset-widgets">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-light rounded-pill px-3">Reset to Default</button>
|
||||
</form>
|
||||
<button class="btn btn-sm btn-dark rounded-pill px-3" id="btn-save-widgets">
|
||||
<i class="bi bi-check2 me-1"></i>Save Layout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2" id="widget-toggle-list">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php $allowed = !$widget['permission'] || auth()->user()->can($widget['permission']); @endphp
|
||||
@if ($allowed)
|
||||
<div class="col-6 col-md-4 col-lg-3">
|
||||
<div class="widget-toggle-item {{ $widget['visible'] ? 'active' : '' }}" data-widget-key="{{ $key }}">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-grip-vertical text-muted"></i>
|
||||
<span class="small fw-semibold">{{ $widget['label'] }}</span>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input widget-visibility-toggle" type="checkbox"
|
||||
data-widget="{{ $key }}" {{ $widget['visible'] ? 'checked' : '' }}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Stat Card Widgets (Top Row) ──────────────────── --}}
|
||||
<div class="row g-3 g-lg-4 mb-4" id="widget-grid-stats">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php
|
||||
$isStat = in_array($key, ['cpu', 'ram', 'disk', 'live_users', 'queues']);
|
||||
$allowed = !$widget['permission'] || auth()->user()->can($widget['permission']);
|
||||
@endphp
|
||||
@if ($isStat && $allowed)
|
||||
<div class="col-6 col-sm-4 col-md-3 col-xl widget-col {{ !$widget['visible'] ? 'hidden-widget' : '' }}"
|
||||
data-widget-key="{{ $key }}" data-sort="{{ $widget['sort_order'] }}">
|
||||
@include('pages.dashboard.widget-' . str_replace('_', '-', $key))
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Big Widgets (Bottom Row - Dynamic Sizing) ────── --}}
|
||||
@php
|
||||
$bigKeys = ['activity_feed', 'ai_insight'];
|
||||
$visibleBig = collect($widgets)
|
||||
->only($bigKeys)
|
||||
->filter(fn($w) => $w['visible'] && (!$w['permission'] || auth()->user()->can($w['permission'])));
|
||||
$vCount = $visibleBig->count();
|
||||
|
||||
// Dynamic class based on user request: 1->12, 2->6, 3->4
|
||||
$bigColClass = 'col-12';
|
||||
if ($vCount === 2) $bigColClass = 'col-12 col-lg-6';
|
||||
if ($vCount >= 3) $bigColClass = 'col-12 col-lg-4';
|
||||
@endphp
|
||||
|
||||
<div class="row g-3 g-lg-4" id="widget-grid-big">
|
||||
@foreach ($widgets as $key => $widget)
|
||||
@php
|
||||
$isBig = in_array($key, $bigKeys);
|
||||
$allowed = !$widget['permission'] || auth()->user()->can($widget['permission']);
|
||||
@endphp
|
||||
@if ($isBig && $allowed)
|
||||
<div class="widget-col big-widget-col {{ !$widget['visible'] ? 'hidden-widget' : '' }}"
|
||||
data-widget-key="{{ $key }}" data-sort="{{ $widget['sort_order'] }}">
|
||||
|
||||
@if ($key === 'activity_feed')
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 animate__animated animate__fadeIn">
|
||||
<div class="card-header bg-white border-bottom p-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-terminal text-theme-1"></i>
|
||||
{{ __('Runtime Activity Feed') }}
|
||||
<span class="live-dot ms-1" title="Live via Reverb"></span>
|
||||
</h6>
|
||||
<a href="{{ route('system-monitoring') }}" class="btn btn-sm btn-light rounded-pill px-3 fw-bold small">
|
||||
<i class="bi bi-gear me-1"></i> FULL MONITOR
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<style>
|
||||
#logs-datatable tbody td:nth-child(3) { white-space: normal !important; min-width: 250px; max-width: 400px; word-break: break-word; }
|
||||
#logs-datatable thead th { white-space: nowrap; }
|
||||
</style>
|
||||
<table id="logs-datatable" class="table table-hover align-middle mb-0 w-100 small compact-table">
|
||||
<thead>
|
||||
<tr class="bg-white">
|
||||
<th>INCIDENT TIME</th><th>LVL</th><th>MANIFEST</th>
|
||||
<th class="text-end pe-4">INTEL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($key === 'ai_insight')
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 animate__animated animate__fadeIn" style="animation-delay:.5s">
|
||||
<div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-robot text-theme-1"></i>
|
||||
{{ __('AI Security Insight') }}
|
||||
</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill px-3 extra-small" id="btn-ai-clear">
|
||||
<i class="bi bi-trash me-1"></i>Clear
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-dark rounded-pill px-3 extra-small" id="btn-ai-analyze">
|
||||
<i class="bi bi-cpu me-1"></i>Analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body px-4">
|
||||
<div class="small text-muted" style="min-height:200px;line-height:1.6;">
|
||||
<div class="placeholder-glow" id="ai-placeholder" style="display:none;">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<span class="placeholder col-12 rounded-pill py-2"></span>
|
||||
<span class="placeholder col-10 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-11 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-8 rounded-pill py-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ai-content-display" class="animate__animated animate__fadeIn">
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-robot display-4 d-block mb-3"></i>
|
||||
<p class="fst-italic">Click analyze to get security insights from your recent activity logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- ── Empty State ───────────────────────────────────── --}}
|
||||
<div id="dashboard-empty-state" class="text-center py-5 mt-4 animate__animated animate__fadeIn" style="{{ collect($widgets)->contains('visible', true) ? 'display:none' : '' }}">
|
||||
<div class="card adminuiux-card border-0 shadow-sm p-5 mx-auto" style="max-width: 500px; border-radius: 24px;">
|
||||
<div class="mb-4">
|
||||
<div class="bg-light rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
||||
<i class="bi bi-grid text-muted display-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="fw-bold">Your dashboard is empty</h4>
|
||||
<p class="text-muted">It seems you have hidden all widgets. Customize your dashboard to display the metrics that matter to you.</p>
|
||||
<button class="btn btn-theme-1 rounded-pill px-4 mt-2" onclick="$('#widget-customize-panel').slideDown();">
|
||||
<i class="bi bi-plus-lg me-1"></i> Customize Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Log Detail Modal - Terminal Style --}}
|
||||
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content border-0 overflow-hidden" style="background:#0d1117;border-radius:12px;box-shadow:0 32px 80px rgba(0,0,0,0.6);">
|
||||
|
||||
{{-- Terminal Title Bar --}}
|
||||
<div class="d-flex align-items-center justify-content-between px-4 py-3" style="background:#161b22;border-bottom:1px solid #30363d;">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{{-- macOS-style traffic light dots --}}
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#ff5f57;display:inline-block;"></span>
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#febc2e;display:inline-block;"></span>
|
||||
<span style="width:13px;height:13px;border-radius:50%;background:#28c840;display:inline-block;"></span>
|
||||
<span class="ms-3 fw-bold" id="terminal-title" style="font-family:'JetBrains Mono','Fira Code','Courier New',monospace;font-size:0.78rem;color:#8b949e;letter-spacing:1px;">TELEMETRY_DUMP @node_01</span>
|
||||
</div>
|
||||
<button type="button" data-bs-dismiss="modal" class="border-0 bg-transparent p-0" style="color:#8b949e;font-size:1.1rem;line-height:1;">×</button>
|
||||
</div>
|
||||
|
||||
{{-- Terminal Body --}}
|
||||
<div class="p-4" style="max-height:72vh;overflow-y:auto;">
|
||||
<div id="terminal-output" style="font-family:'JetBrains Mono','Fira Code','Courier New',monospace;font-size:0.8rem;line-height:1.8;color:#3fb950;white-space:pre-wrap;word-break:break-word;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{{-- /container-fluid --}}
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
|
||||
// ── Sparkline charts (always rendered for stat cards) ─────
|
||||
const sparkOpts = (color) => ({
|
||||
series: [{ data: Array(10).fill(0) }],
|
||||
chart: { type: 'area', height: 60, sparkline: { enabled: true }, animations: { enabled: true, easing: 'linear', dynamicAnimation: { speed: 1000 } } },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { opacity: .3, type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: .4, opacityTo: .1 } },
|
||||
colors: [color], tooltip: { enabled: false }
|
||||
});
|
||||
|
||||
const cpuEl = document.querySelector('#chart-cpu-sparkline');
|
||||
const ramEl = document.querySelector('#chart-ram-sparkline');
|
||||
const diskEl = document.querySelector('#chart-disk-sparkline');
|
||||
const cpuChart = cpuEl ? new ApexCharts(cpuEl, sparkOpts('var(--adminuiux-theme-1)')) : null;
|
||||
const ramChart = ramEl ? new ApexCharts(ramEl, sparkOpts('#0dcaf0')) : null;
|
||||
const diskChart = diskEl ? new ApexCharts(diskEl, sparkOpts('#ffc107')) : null;
|
||||
cpuChart?.render(); ramChart?.render(); diskChart?.render();
|
||||
|
||||
function pushSparkline(chart, val) {
|
||||
if (!chart) return;
|
||||
let d = chart.w.globals.series[0].slice();
|
||||
d.push(val); if (d.length > 10) d.shift();
|
||||
chart.updateSeries([{ data: d }]);
|
||||
}
|
||||
|
||||
function updateVal(sel, newVal) {
|
||||
const el = $(sel);
|
||||
if (el.text() !== String(newVal)) {
|
||||
el.text(newVal).addClass('animate__animated animate__pulse');
|
||||
setTimeout(() => el.removeClass('animate__animated animate__pulse'), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStats(d) {
|
||||
if (d.cpu !== undefined) { updateVal('#stat-cpu-percent', d.cpu + '%'); $('#cpu-bar').css('width', d.cpu + '%'); pushSparkline(cpuChart, d.cpu); }
|
||||
if (d.ram) { updateVal('#stat-ram-percent', d.ram.percentage + '%'); $('#stat-ram-used').text(d.ram.used + ' used'); if (d.ram.swap) $('#stat-swap-info').text('Swap: ' + d.ram.swap.percentage + '%'); pushSparkline(ramChart, d.ram.percentage); }
|
||||
if (d.disk) { updateVal('#stat-disk-percent', d.disk.percentage + '%'); $('#stat-disk-total').text(d.disk.free + ' available'); pushSparkline(diskChart, d.disk.percentage); }
|
||||
if (d.users){ updateVal('#stat-users-count', d.users.total); $('#stat-users-auth').text(d.users.authenticated); }
|
||||
if (d.queues){ updateVal('#stat-queues-pending', d.queues.pending); updateVal('#stat-queues-failed', d.queues.failed); }
|
||||
if (d.uptime) $('#stat-uptime-badge').text(d.uptime);
|
||||
const reverbOn = d.has_reverb;
|
||||
if (reverbOn !== undefined) {
|
||||
$('#reverb-icon').toggleClass('active', !!reverbOn);
|
||||
$('#reverb-status-text').text(reverbOn ? 'ACTIVE' : 'IDLE').toggleClass('text-success', !!reverbOn).toggleClass('text-muted', !reverbOn);
|
||||
}
|
||||
}
|
||||
|
||||
@can('view health and logs')
|
||||
// ── Activity DataTable ─────────────────────────────────────
|
||||
const logsTable = $('#logs-datatable').DataTable({
|
||||
processing: true, serverSide: true,
|
||||
ajax: '{{ route("system-monitoring.logs.datatable") }}',
|
||||
order: [[0,'desc']], pageLength: 5, autoWidth: false,
|
||||
dom: 'tr<"p-3 border-top d-flex justify-content-end"p>',
|
||||
columns: [
|
||||
{ data: 0, className: 'ps-4 datetime-col fw-bold' },
|
||||
{ data: 1 }, { data: 2 },
|
||||
{ data: 3, className: 'pe-4 text-end', orderable: false }
|
||||
],
|
||||
drawCallback: function () {
|
||||
$('.view-log-detail').off('click').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
renderTerminalModal(log);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
}
|
||||
});
|
||||
@endcan
|
||||
|
||||
// ── Manual refresh ─────────────────────────────────────────
|
||||
function refreshStats() {
|
||||
const btn = $('#refresh-all-stats');
|
||||
btn.find('i').addClass('bi-spin');
|
||||
$.get('{{ route("system-monitoring.stats") }}', function (d) {
|
||||
applyStats(d);
|
||||
@can('view health and logs')
|
||||
logsTable.ajax.reload(null, false);
|
||||
@endcan
|
||||
setTimeout(() => btn.find('i').removeClass('bi-spin'), 1000);
|
||||
});
|
||||
}
|
||||
$('#refresh-all-stats').on('click', refreshStats);
|
||||
|
||||
// ── Real-time via Reverb ───────────────────────────────────
|
||||
if (window.Echo) {
|
||||
@can('view health and logs')
|
||||
window.Echo.private('admin.monitoring')
|
||||
.listen('.stats.updated', applyStats)
|
||||
.listen('.activity.created', (e) => {
|
||||
const rowNode = logsTable.row.add([
|
||||
e.log.datetime, e.log.level, e.log.manifest,
|
||||
`<button class="btn btn-square btn-light btn-sm rounded-circle view-log-detail"
|
||||
data-log='${JSON.stringify({ message: e.log.description }).replace(/'/g,"'")}'>
|
||||
<i class="bi bi-info-circle"></i></button>`
|
||||
]).draw(false).node();
|
||||
$(rowNode).css('background-color','#fff9c4');
|
||||
setTimeout(() => $(rowNode).css('background-color',''), 3000);
|
||||
$(rowNode).find('.view-log-detail').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
renderTerminalModal(log);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
});
|
||||
@endcan
|
||||
} else {
|
||||
// Fallback polling when Reverb not connected
|
||||
setInterval(refreshStats, 30000);
|
||||
}
|
||||
|
||||
// ── Terminal Modal Renderer ────────────────────────────────
|
||||
function renderTerminalModal(log) {
|
||||
const msg = log.message || '';
|
||||
// Format markdown-ish text for terminal display
|
||||
const formatted = msg
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1') // strip bold markers, keep text
|
||||
.replace(/^#+\s+/gm, '>> ') // headings → terminal prefix
|
||||
.replace(/^(\d+\.)/gm, ' $1'); // indent numbered lists
|
||||
$('#terminal-output').text(formatted);
|
||||
// Animate a typing cursor effect
|
||||
$('#terminal-output').append('<span id="cursor-blink" style="display:inline-block;width:8px;height:14px;background:#3fb950;vertical-align:middle;margin-left:4px;animation:blink-cursor 1s step-start infinite;"></span>');
|
||||
}
|
||||
|
||||
// ── AI Analysis ────────────────────────────────────────────
|
||||
function renderAiContent(text) {
|
||||
const html = text
|
||||
.replace(/### (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/## (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
$('#ai-content-display').html(html);
|
||||
}
|
||||
|
||||
@can('view ai log analysis')
|
||||
$.get('{{ route("ai.log-analysis.index") }}', function (d) {
|
||||
if (d.analysis && !d.analysis.includes('Analysis not generated yet')) renderAiContent(d.analysis);
|
||||
});
|
||||
|
||||
$('#btn-ai-analyze').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Analyzing...');
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$('#ai-placeholder').show();
|
||||
$.post('{{ route("ai.log-analysis.analyze") }}', { _token: '{{ csrf_token() }}' }, function (d) {
|
||||
renderAiContent(d.analysis);
|
||||
$('#ai-placeholder').hide();
|
||||
$('#ai-content-display').fadeIn();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
}).fail(function () {
|
||||
$('#ai-content-display').html('<div class="alert alert-danger p-2 small"><i class="bi bi-exclamation-triangle me-2"></i> Error connecting to AI service.</div>').fadeIn();
|
||||
$('#ai-placeholder').hide();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-ai-clear').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
$.post('{{ route("ai.log-analysis.clear") }}', { _token: '{{ csrf_token() }}' }, function () {
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$(this).html(`<div class="text-center py-5 opacity-50"><i class="bi bi-robot display-4 d-block mb-3"></i><p class="fst-italic">Click analyze to get security insights.</p></div>`).fadeIn();
|
||||
});
|
||||
btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear');
|
||||
}).fail(() => btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear'));
|
||||
});
|
||||
@endcan
|
||||
|
||||
// ── Widget Customize ───────────────────────────────────────
|
||||
$('#btn-customize-widgets').on('click', function () {
|
||||
$('#widget-customize-panel').slideToggle(200);
|
||||
});
|
||||
|
||||
function updateBigWidgetLayout() {
|
||||
const visible = $('#widget-grid-big .widget-col:not(.hidden-widget)');
|
||||
const vCount = visible.length;
|
||||
let colClass = 'col-12';
|
||||
if (vCount === 2) colClass = 'col-12 col-lg-6';
|
||||
if (vCount >= 3) colClass = 'col-12 col-lg-4';
|
||||
|
||||
$('#widget-grid-big .widget-col')
|
||||
.removeClass('col-12 col-lg-6 col-lg-4')
|
||||
.addClass(colClass);
|
||||
}
|
||||
updateBigWidgetLayout(); // Initial call
|
||||
|
||||
$(document).on('change', '.widget-visibility-toggle', function () {
|
||||
const key = $(this).data('widget');
|
||||
const visible = $(this).is(':checked');
|
||||
$(this).closest('.widget-toggle-item').toggleClass('active', visible);
|
||||
$(`[data-widget-key="${key}"]`).toggleClass('hidden-widget', !visible);
|
||||
|
||||
updateBigWidgetLayout();
|
||||
|
||||
// Show/hide empty state
|
||||
const anyVisible = $('.widget-visibility-toggle:checked').length > 0;
|
||||
$('#dashboard-empty-state').toggle(!anyVisible);
|
||||
});
|
||||
|
||||
$('#btn-toggle-all-widgets').on('click', function() {
|
||||
const allChecked = $('.widget-visibility-toggle:checked').length === $('.widget-visibility-toggle').length;
|
||||
$('.widget-visibility-toggle').prop('checked', !allChecked).trigger('change');
|
||||
});
|
||||
|
||||
// Drag-to-reorder stats
|
||||
if (document.getElementById('widget-grid-stats')) {
|
||||
Sortable.create(document.getElementById('widget-grid-stats'), {
|
||||
animation: 150, handle: '.card', ghostClass: 'widget-ghost', chosenClass: 'widget-chosen',
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-to-reorder big widgets
|
||||
if (document.getElementById('widget-grid-big')) {
|
||||
Sortable.create(document.getElementById('widget-grid-big'), {
|
||||
animation: 150, handle: '.card', ghostClass: 'widget-ghost', chosenClass: 'widget-chosen',
|
||||
});
|
||||
}
|
||||
|
||||
// Save layout
|
||||
$('#btn-save-widgets').on('click', function () {
|
||||
const btn = $(this);
|
||||
const originalHtml = btn.html();
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Saving...');
|
||||
|
||||
const widgets = [];
|
||||
// Collect from both grids
|
||||
$('.widget-col').each(function (i) {
|
||||
widgets.push({
|
||||
key: $(this).data('widget-key'),
|
||||
visible: !$(this).hasClass('hidden-widget'),
|
||||
sort_order: i + 1,
|
||||
});
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: '{{ route("dashboard.widgets.save") }}',
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ widgets }),
|
||||
success: () => {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-check2 me-1"></i>Saved!');
|
||||
setTimeout(() => btn.html(originalHtml), 2000);
|
||||
if (window.showNotificationToast) {
|
||||
window.showNotificationToast('success', 'Dashboard layout updated successfully');
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-exclamation-circle me-1"></i>Error');
|
||||
setTimeout(() => btn.html(originalHtml), 2000);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$('#form-reset-widgets').on('submit', function() {
|
||||
$(this).find('button').prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,677 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
.sparkline-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Skeleton Loading */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e6e6e6 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 2.5rem;
|
||||
width: 60%;
|
||||
margin: 10px auto;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
<div class="container-fluid mt-3 pb-5" id="monitoring-master">
|
||||
|
||||
{{-- Welcome Header --}}
|
||||
<div
|
||||
class="card adminuiux-card bg-dark text-white mb-4 border-0 shadow-lg overflow-hidden animate__animated animate__fadeIn">
|
||||
<div class="card-body p-4 position-relative">
|
||||
<div class="row align-items-center position-relative z-1">
|
||||
<div class="col">
|
||||
<h1 class="display-5 fw-bold text-white mb-1 tracking-tight">{{ __('Operational Dashboard') }}
|
||||
</h1>
|
||||
<p class="small text-white-50 mb-0 d-flex align-items-center gap-2">
|
||||
<span class="status-indicator"></span>
|
||||
System operational since <span class="badge text-bg-theme-1 rounded-pill px-3 shadow-sm"
|
||||
id="stat-uptime-badge">{{ $stats['uptime'] }}</span>
|
||||
at <span class="fw-bold">{{ $stats['hostname'] }}</span> ({{ $stats['ip'] }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-theme-1 btn-square rounded-circle shadow-sm" id="refresh-all-stats"
|
||||
title="Refresh Live Data">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-decoration"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Main Stats Row --}}
|
||||
<div class="row g-3 g-lg-4 mb-4">
|
||||
{{-- CPU --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.1s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">CPU LOAD</h6>
|
||||
<i class="bi bi-speedometer2 text-theme-1"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-theme-1 mb-0 counter-value" id="stat-cpu-percent">
|
||||
{{ $stats['cpu'] }}%
|
||||
</h1>
|
||||
<div class="mini-progress mt-3">
|
||||
<div class="bar" id="cpu-bar" style="width:{{$stats['cpu']}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-cpu-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RAM --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.2s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">MEMORY</h6>
|
||||
<i class="bi bi-memory text-info"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-info mb-0 counter-value" id="stat-ram-percent">
|
||||
{{ $stats['ram']['percentage'] }}%
|
||||
</h1>
|
||||
<div class="d-flex justify-content-between extra-small text-muted mt-2">
|
||||
<span id="stat-ram-used">{{ $stats['ram']['used'] }} used</span>
|
||||
<span id="stat-swap-info" class="text-warning">Swap:
|
||||
{{ $stats['ram']['swap']['percentage'] ?? 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-ram-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- DISK --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.3s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">STORAGE</h6>
|
||||
<i class="bi bi-hdd-network text-warning"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-warning mb-0 counter-value" id="stat-disk-percent">
|
||||
{{ $stats['disk']['percentage'] }}%
|
||||
</h1>
|
||||
<p class="extra-small text-muted mb-0 mt-2" id="stat-disk-total">
|
||||
{{ $stats['disk']['free'] }} available
|
||||
</p>
|
||||
</div>
|
||||
<div id="chart-disk-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SESSIONS --}}
|
||||
<div class="col-6 col-md-6 col-lg-3">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift bg-theme-1 text-white card-glow-theme animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.4s">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-white small mb-0">LIVE USERS</h6>
|
||||
<i class="bi bi-people text-white-50"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-white mb-0 counter-value" id="stat-users-count">
|
||||
{{ $stats['users']['total'] }}
|
||||
</h1>
|
||||
<p class="extra-small text-white-50 mb-0 mt-2">
|
||||
<span id="stat-users-auth">{{ $stats['users']['authenticated'] }}</span> Authenticated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 g-lg-6">
|
||||
@if(auth()->user()->can('view health and logs'))
|
||||
|
||||
{{-- Right Column: Live Console (Logs) --}}
|
||||
<div class="col-lg-7">
|
||||
<div
|
||||
class="card adminuiux-card border-0 shadow-sm h-100 mb-4 mb-lg-0 animate__animated animate__fadeIn">
|
||||
<div
|
||||
class="card-header bg-white border-bottom p-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-terminal text-theme-1"></i>
|
||||
{{ __('Runtime Activity Feed') }}
|
||||
</h6>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ route('system-monitoring') }}"
|
||||
class="btn btn-sm btn-light rounded-pill px-3 fw-bold small">
|
||||
<i class="bi bi-gear me-1"></i> FULL MONITOR
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<style>
|
||||
#logs-datatable tbody td:nth-child(3) {
|
||||
white-space: normal !important;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
word-break: break-word;
|
||||
}
|
||||
#logs-datatable thead th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<table id="logs-datatable"
|
||||
class="table table-hover align-middle mb-0 w-100 small compact-table">
|
||||
<thead>
|
||||
<tr class="bg-white">
|
||||
<th>INCIDENT TIME</th>
|
||||
<th>LVL</th>
|
||||
<th>MANIFEST</th>
|
||||
<th class="text-end pe-4">INTEL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('view ai log analysis')
|
||||
|
||||
{{-- AI Analysis Card --}}
|
||||
<div class="col-lg-5">
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 mb-4 mb-lg-0 animate__animated animate__fadeIn"
|
||||
style="animation-delay: 0.5s">
|
||||
<div
|
||||
class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||
<h6 class="fw-bold text-dark mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-robot text-theme-1"></i>
|
||||
{{ __('AI Security Insight') }}
|
||||
</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-danger rounded-pill px-3 extra-small"
|
||||
id="btn-ai-clear">
|
||||
<i class="bi bi-trash me-1"></i>Clear
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-dark rounded-pill px-3 extra-small"
|
||||
id="btn-ai-analyze">
|
||||
<i class="bi bi-cpu me-1"></i>Analyze
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body px-4">
|
||||
<div id="ai-analysis-container" class="small text-muted"
|
||||
style="min-height: 200px; line-height: 1.6;">
|
||||
<div class="placeholder-glow" id="ai-placeholder" style="display:none;">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<span class="placeholder col-12 rounded-pill py-2"></span>
|
||||
<span class="placeholder col-10 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-11 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-8 rounded-pill py-1"></span>
|
||||
<span class="placeholder col-9 rounded-pill py-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ai-content-display" class="animate__animated animate__fadeIn">
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-robot display-4 d-block mb-3"></i>
|
||||
<p class="fst-italic">Click analyze to get security insights from your recent
|
||||
activity logs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Log Detail Modal --}}
|
||||
<div class="modal fade animate__animated animate__fadeIn" id="logDetailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl animate__animated animate__fadeIn">
|
||||
<div class="modal-content adminuiux-card border-0 shadow-2xl terminal-box">
|
||||
<div class="modal-header terminal-header">
|
||||
<div class="window-controls me-3">
|
||||
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||
</div>
|
||||
<h6 class="modal-title fw-bold extra-small text-white-50 letter-spacing-1">TELEMETRY_DUMP @node_01
|
||||
</h6>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="code-container p-4">
|
||||
<pre id="detail-message" class="mb-0 small text-success font-monospace scroll-custom"
|
||||
style="max-height: 700px; overflow-y: auto; overflow-x: hidden; line-height: 1.6; white-space: pre-wrap; word-break: break-all;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
@if(auth()->user()->can('view health and logs'))
|
||||
const REFRESH_RATE = 10000; // 10s for dashboard to be less aggressive
|
||||
|
||||
// Logs Init
|
||||
const logsTable = $('#logs-datatable').DataTable({
|
||||
processing: true, serverSide: true, ajax: '{{ route("system-monitoring.logs.datatable") }}',
|
||||
order: [[0, 'desc']], pageLength: 5, autoWidth: false, dom: 'tr<"p-3 border-top d-flex justify-content-end"p>',
|
||||
columns: [{ data: 0, className: 'ps-4 datetime-col fw-bold' }, { data: 1 }, { data: 2 }, { data: 3, className: 'pe-4 text-end', orderable: false }],
|
||||
drawCallback: function () {
|
||||
$('.view-log-detail').off('click').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
$('#detail-message').text(log.message);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function refreshStats() {
|
||||
const btn = $('#refresh-all-stats');
|
||||
btn.find('i').addClass('bi-spin');
|
||||
|
||||
$.get('{{ route("system-monitoring.stats") }}', function (d) {
|
||||
updateVal('#stat-cpu-percent', d.cpu + '%');
|
||||
updateVal('#stat-ram-percent', d.ram.percentage + '%');
|
||||
$('#stat-ram-used').text(d.ram.used + ' used');
|
||||
if (d.ram.swap) {
|
||||
$('#stat-swap-info').text('Swap: ' + d.ram.swap.percentage + '%');
|
||||
}
|
||||
|
||||
updateVal('#stat-disk-percent', d.disk.percentage + '%');
|
||||
$('#stat-disk-total').text(d.disk.free + ' available');
|
||||
|
||||
// Refined Users
|
||||
updateVal('#stat-users-count', d.users.total);
|
||||
$('#stat-users-auth').text(d.users.authenticated);
|
||||
|
||||
updateVal('#stat-queues-pending', d.queues.pending);
|
||||
updateVal('#stat-queues-failed', d.queues.failed);
|
||||
|
||||
$('#stat-uptime-badge').text(d.uptime);
|
||||
$('#cpu-bar').css('width', d.cpu + '%');
|
||||
|
||||
// Update Charts
|
||||
updateSparkline(cpuChart, d.cpu);
|
||||
updateSparkline(ramChart, d.ram.percentage);
|
||||
updateSparkline(diskChart, d.disk.percentage);
|
||||
|
||||
if (d.has_reverb) {
|
||||
$('#reverb-icon').addClass('active');
|
||||
$('#reverb-status-text').text('ACTIVE').removeClass('text-muted').addClass('text-success');
|
||||
} else {
|
||||
$('#reverb-icon').removeClass('active');
|
||||
$('#reverb-status-text').text('IDLE').addClass('text-muted').removeClass('text-success');
|
||||
}
|
||||
|
||||
logsTable.ajax.reload(null, false);
|
||||
setTimeout(() => btn.find('i').removeClass('bi-spin'), 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sparkline Helpers ---
|
||||
const sparklineOptions = (color) => ({
|
||||
series: [{ data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] }],
|
||||
chart: { type: 'area', height: 60, sparkline: { enabled: true }, animations: { enabled: true, easing: 'linear', dynamicAnimation: { speed: 1000 } } },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
fill: { opacity: 0.3, type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.1 } },
|
||||
colors: [color],
|
||||
tooltip: { enabled: false }
|
||||
});
|
||||
|
||||
const cpuChart = new ApexCharts(document.querySelector("#chart-cpu-sparkline"), sparklineOptions('var(--adminuiux-theme-1)'));
|
||||
const ramChart = new ApexCharts(document.querySelector("#chart-ram-sparkline"), sparklineOptions('#0dcaf0'));
|
||||
const diskChart = new ApexCharts(document.querySelector("#chart-disk-sparkline"), sparklineOptions('#ffc107'));
|
||||
|
||||
cpuChart.render();
|
||||
ramChart.render();
|
||||
diskChart.render();
|
||||
|
||||
function updateSparkline(chart, val) {
|
||||
let newData = chart.w.globals.series[0].slice();
|
||||
newData.push(val);
|
||||
if (newData.length > 10) newData.shift();
|
||||
chart.updateSeries([{ data: newData }]);
|
||||
}
|
||||
|
||||
function updateVal(selector, newVal) {
|
||||
const el = $(selector);
|
||||
if (el.text() !== newVal.toString()) {
|
||||
el.text(newVal).addClass('animate__animated animate__pulse');
|
||||
setTimeout(() => el.removeClass('animate__animated animate__pulse'), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
$('#refresh-all-stats').on('click', refreshStats);
|
||||
setInterval(refreshStats, REFRESH_RATE);
|
||||
|
||||
// 📡 Real-time Activity Feed (Broadcasting)
|
||||
if (window.Echo) {
|
||||
window.Echo.private('admin.monitoring')
|
||||
.listen('.activity.created', (e) => {
|
||||
console.log('Real-time Log received:', e.log);
|
||||
|
||||
// Prepend to DataTable
|
||||
const rowNode = logsTable.row.add([
|
||||
e.log.datetime,
|
||||
e.log.level,
|
||||
e.log.manifest,
|
||||
`<button class="btn btn-square btn-light btn-sm rounded-circle view-log-detail" data-log='${JSON.stringify({ message: e.log.description }).replace(/'/g, "'")}'>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>`
|
||||
]).draw(false).node();
|
||||
|
||||
$(rowNode).addClass('animate__animated animate__highlight').css('background-color', '#fff9c4');
|
||||
setTimeout(() => $(rowNode).css('background-color', ''), 3000);
|
||||
|
||||
// Re-bind modal click
|
||||
$(rowNode).find('.view-log-detail').on('click', function () {
|
||||
const log = $(this).data('log');
|
||||
$('#detail-message').text(log.message);
|
||||
new bootstrap.Modal('#logDetailModal').show();
|
||||
});
|
||||
});
|
||||
}
|
||||
@endif
|
||||
|
||||
// 🤖 AI Log Analysis JS
|
||||
function fetchAiAnalysis() {
|
||||
$.get('{{ route("ai.log-analysis.index") }}', function (d) {
|
||||
if (d.analysis && !d.analysis.includes('Analysis not generated yet')) {
|
||||
renderAiContent(d.analysis);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAiContent(text) {
|
||||
// Simple markdown-ish bold headers conversion
|
||||
let formatted = text.replace(/### (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/## (.*)/g, '<h6 class="fw-bold text-dark mt-3 mb-2">$1</h6>')
|
||||
.replace(/\*\*(.*)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n/g, '<br>');
|
||||
$('#ai-content-display').html(formatted);
|
||||
}
|
||||
|
||||
$('#btn-ai-analyze').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Analyzing...');
|
||||
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$('#ai-placeholder').show();
|
||||
|
||||
$.post('{{ route("ai.log-analysis.analyze") }}', { _token: '{{ csrf_token() }}' }, function (d) {
|
||||
renderAiContent(d.analysis);
|
||||
$('#ai-placeholder').hide();
|
||||
$('#ai-content-display').fadeIn();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
}).fail(function () {
|
||||
$('#ai-content-display').html('<div class="alert alert-danger p-2 small"><i class="bi bi-exclamation-triangle me-2"></i> Error connecting to AI service.</div>').fadeIn();
|
||||
$('#ai-placeholder').hide();
|
||||
btn.prop('disabled', false).html('<i class="bi bi-cpu me-1"></i>Analyze');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$('#btn-ai-clear').on('click', function () {
|
||||
const btn = $(this);
|
||||
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
|
||||
$.post('{{ route("ai.log-analysis.clear") }}', { _token: '{{ csrf_token() }}' }, function (d) {
|
||||
$('#ai-content-display').fadeOut(200, function () {
|
||||
$(this).html(`
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-robot display-4 d-block mb-3"></i>
|
||||
<p class="fst-italic">Click analyze to get security insights from your recent activity logs.</p>
|
||||
</div>
|
||||
`).fadeIn();
|
||||
});
|
||||
btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear');
|
||||
}).fail(function () {
|
||||
btn.prop('disabled', false).html('<i class="bi bi-trash me-1"></i>Clear');
|
||||
});
|
||||
});
|
||||
|
||||
fetchAiAnalysis();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.fw-black {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.ls-1 {
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.ls-2 {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.tracking-tight {
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.display-3 {
|
||||
font-size: 3.5rem;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.extra-small {
|
||||
font-size: 0.85rem !important;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
right: -50px;
|
||||
bottom: -50px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.icon-shape {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bg-theme-1-subtle {
|
||||
background-color: rgba(var(--adminuiux-theme-1-rgb), 0.1);
|
||||
}
|
||||
|
||||
.bg-info-subtle {
|
||||
background-color: rgba(13, 202, 240, 0.1);
|
||||
}
|
||||
|
||||
.bg-warning-subtle {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.bg-success-subtle {
|
||||
background-color: rgba(25, 135, 84, 0.1);
|
||||
}
|
||||
|
||||
.bg-danger-subtle {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.icon-box-modern {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hover-bg-light:hover {
|
||||
background-color: #f8fafc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pulse-tiny {
|
||||
animation: pulse-glow-tiny 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow-tiny {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.bi-spin {
|
||||
animation: spin 1.5s infinite linear;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.compact-table thead th {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #64748b;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.compact-table tbody td {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.scroll-custom::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scroll-custom::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scroll-custom::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
font-size: 0.7rem !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-bottom: 0.4rem !important;
|
||||
}
|
||||
|
||||
/* Terminal Styling */
|
||||
.terminal-box {
|
||||
background: #0c121e !important;
|
||||
border: 1px solid #1e293b !important;
|
||||
color: #10b981 !important;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #1e293b !important;
|
||||
border-bottom: 1px solid #334155 !important;
|
||||
padding: 12px 20px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.window-controls .dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.dot.yellow {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.code-container pre {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', monospace !important;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="cpu">
|
||||
<div class="card-body p-4 text-center position-relative">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">CPU LOAD</h6>
|
||||
<i class="bi bi-speedometer2 text-theme-1"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-theme-1 mb-0 counter-value" id="stat-cpu-percent">{{ $stats['cpu'] }}%</h1>
|
||||
<div class="mini-progress mt-3">
|
||||
<div class="bar" id="cpu-bar" style="width:{{ $stats['cpu'] }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-cpu-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="disk">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">STORAGE</h6>
|
||||
<i class="bi bi-hdd-network text-warning"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-warning mb-0 counter-value" id="stat-disk-percent">{{ $stats['disk']['percentage'] }}%</h1>
|
||||
<p class="extra-small text-muted mb-0 mt-2" id="stat-disk-total">{{ $stats['disk']['free'] }} available</p>
|
||||
</div>
|
||||
<div id="chart-disk-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift bg-theme-1 text-white card-glow-theme" data-widget="live_users">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-white small mb-0">LIVE USERS</h6>
|
||||
<i class="bi bi-people text-white-50"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-white mb-0 counter-value" id="stat-users-count">{{ $stats['users']['total'] }}</h1>
|
||||
<p class="extra-small text-white-50 mb-0 mt-2">
|
||||
<span id="stat-users-auth">{{ $stats['users']['authenticated'] }}</span> Authenticated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="queues">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h6 class="fw-bold text-dark small mb-0">QUEUE STATS</h6>
|
||||
<i class="bi bi-stack text-secondary"></i>
|
||||
</div>
|
||||
<div class="row g-2 text-center">
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded-3 bg-light">
|
||||
<div class="fw-black display-6 text-dark counter-value" id="stat-queues-pending">{{ $stats['queues']['pending'] ?? 0 }}</div>
|
||||
<div class="extra-small text-muted fw-semibold">PENDING</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="p-2 rounded-3 bg-danger-subtle">
|
||||
<div class="fw-black display-6 text-danger counter-value" id="stat-queues-failed">{{ $stats['queues']['failed'] ?? 0 }}</div>
|
||||
<div class="extra-small text-danger fw-semibold">FAILED</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 d-flex align-items-center gap-2">
|
||||
<span class="extra-small text-muted">Reverb:</span>
|
||||
<i class="bi bi-broadcast text-muted" id="reverb-icon"></i>
|
||||
<span class="extra-small text-muted" id="reverb-status-text">IDLE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="card adminuiux-card border-0 shadow-sm h-100 hover-lift" data-widget="ram">
|
||||
<div class="card-body p-4 text-center">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h6 class="fw-bold text-dark small mb-0">MEMORY</h6>
|
||||
<i class="bi bi-memory text-info"></i>
|
||||
</div>
|
||||
<h1 class="display-3 fw-black text-info mb-0 counter-value" id="stat-ram-percent">{{ $stats['ram']['percentage'] }}%</h1>
|
||||
<div class="d-flex justify-content-between extra-small text-muted mt-2">
|
||||
<span id="stat-ram-used">{{ $stats['ram']['used'] }} used</span>
|
||||
<span id="stat-swap-info" class="text-warning">Swap: {{ $stats['ram']['swap']['percentage'] ?? 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart-ram-sparkline" class="sparkline-container"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,597 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet" />
|
||||
<link href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css" rel="stylesheet" />
|
||||
<style>
|
||||
:root {
|
||||
--admin-bg: #f5f7fb;
|
||||
--tab-active-bg: #ffffff;
|
||||
--text-dark: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--admin-bg);
|
||||
}
|
||||
|
||||
.ck-editor__editable {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* FilePond Sleek Styling */
|
||||
.filepond--root {
|
||||
margin-bottom: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.filepond--panel-root {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.filepond--drop-label {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.filepond--label-action {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.filepond--credits {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Asset Card Styling */
|
||||
.asset-item-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.asset-preview-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.asset-preview-card img {
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.form-control-sleek {
|
||||
border-radius: 50rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control-sleek:focus {
|
||||
border-color: #94a3b8;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-save-floating {
|
||||
background: #111827;
|
||||
color: white;
|
||||
border-radius: 50rem;
|
||||
padding: 10px 30px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-save-floating:hover {
|
||||
transform: translateY(-2px);
|
||||
background: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Color Picker Styling */
|
||||
.color-preview-circle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px #e2e8f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-preview-circle:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 0 1px #cbd5e1, 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.input-group-text-color {
|
||||
background-color: #f8fafc !important;
|
||||
border-right: none !important;
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
.color-input-field {
|
||||
border-left: none !important;
|
||||
text-transform: uppercase;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Sticky Bottom Bar */
|
||||
.sticky-bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid #e2e8f0;
|
||||
padding: 1rem 2rem;
|
||||
margin: 0 -1.5rem -1.5rem -1.5rem;
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Mobile Mockup CSS */
|
||||
.mobile-mockup-container {
|
||||
width: 280px;
|
||||
height: 580px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #111827;
|
||||
border-radius: 40px;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
box-shadow: 0 50px 100px -20px rgba(0,0,0,0.25), 0 30px 60px -30px rgba(0,0,0,0.3);
|
||||
border: 4px solid #374151;
|
||||
}
|
||||
|
||||
.mobile-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 30px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-home-indicator {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.app-content-preview {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mockup-app-name {
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.x-small {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fw-black {
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
|
||||
.op-75 {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Terminal Styling for JSON Preview */
|
||||
.terminal-box {
|
||||
background: #0c121e !important;
|
||||
border: 1px solid #1e293b !important;
|
||||
color: #10b981 !important;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
background: #1e293b !important;
|
||||
border-bottom: 1px solid #334155 !important;
|
||||
padding: 10px 20px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.window-controls .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.dot.red { background: #ef4444; }
|
||||
.dot.yellow { background: #f59e0b; }
|
||||
.dot.green { background: #22c55e; }
|
||||
|
||||
.terminal-content {
|
||||
padding: 20px;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-content pre {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'Monaco', 'Consolas', monospace !important;
|
||||
color: #10b981 !important;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
<!-- Header Section -->
|
||||
<div class="p-4 pb-0">
|
||||
<div class="row align-items-center g-3 mb-3">
|
||||
<div class="col">
|
||||
<h5 class="mb-0 fw-bold text-dark">{{ __('Mobile Settings') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Manage branding and feature flags with secure, real-time mobile sync.') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="input-group input-group-sm bg-light rounded-pill px-3 py-1 border">
|
||||
<i class="bi bi-search text-muted my-auto me-2"></i>
|
||||
<input type="text" id="mobileSearch" class="form-control border-0 bg-transparent" placeholder="{{ __('Search mobile settings...') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="row gx-4">
|
||||
<!-- Left Side: Settings Form -->
|
||||
<div class="col-xl-8 col-lg-7">
|
||||
<form id="mobileConfigForm" action="{{ route('mobile-settings.update') }}" method="POST" enctype="multipart/form-data"
|
||||
autocomplete="off" class="ajax-form" data-reset="false" data-alert="false">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
@php
|
||||
$groupIcons = [
|
||||
'branding' => 'bi-palette',
|
||||
'control_center' => 'bi-command',
|
||||
'app_updates' => 'bi-cloud-download',
|
||||
'features' => 'bi-stars',
|
||||
'security_auth' => 'bi-shield-lock',
|
||||
'connectivity' => 'bi-wifi',
|
||||
'notifications' => 'bi-bell',
|
||||
'support_social' => 'bi-headset',
|
||||
'analytics_system' => 'bi-cpu',
|
||||
'localization' => 'bi-translate',
|
||||
];
|
||||
@endphp
|
||||
<!-- Nav Tabs -->
|
||||
<div class="overflow-x-auto mb-4" style="ms-overflow-style: -ms-autohiding-scrollbar;">
|
||||
<ul class="nav nav-tabs border-bottom-0 flex-nowrap" id="mobileTabs" role="tablist">
|
||||
@foreach($settings as $group => $items)
|
||||
@cantab('mobile settings', $group)
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link px-3 fw-semibold text-nowrap text-capitalize @if($loop->first) active @endif"
|
||||
id="{{ $group }}-tab" data-bs-toggle="tab" data-bs-target="#{{ $group }}" type="button" role="tab" aria-selected="{{ $loop->first ? 'true' : 'false' }}">
|
||||
<i class="bi {{ $groupIcons[$group] ?? 'bi-gear' }} me-2"></i>
|
||||
{{ str_replace('_', ' ', $group) }}
|
||||
</button>
|
||||
</li>
|
||||
@endcantab
|
||||
@endforeach
|
||||
@cantab('mobile settings', 'developer')
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link px-3 fw-semibold text-nowrap"
|
||||
id="developer-tab" data-bs-toggle="tab" data-bs-target="#developer" type="button" role="tab" aria-selected="false">
|
||||
<i class="bi bi-code-square me-2 text-primary"></i>
|
||||
{{ __('Developer Console') }}
|
||||
</button>
|
||||
</li>
|
||||
@endcantab
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tab-content" id="mobileTabsContent">
|
||||
@foreach($settings as $group => $groupSettings)
|
||||
@cantab('mobile settings', $group)
|
||||
<div class="tab-pane fade @if($loop->first) show active @endif" id="{{ $group }}" role="tabpanel">
|
||||
<div class="row gx-3">
|
||||
<div class="col-12">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<div class="avatar avatar-40 rounded-circle bg-primary-subtle text-primary me-3">
|
||||
<i class="bi {{ $groupIcons[$group] ?? 'bi-gear' }} fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0 text-uppercase tracking-wider text-dark">{{ str_replace('_', ' ', $group) }} {{ __('Configuration') }}</h6>
|
||||
<small class="text-muted">{{ __('Manage mobile specific ' . str_replace('_', ' ', $group) . ' settings and synchronization.') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@foreach($groupSettings as $setting)
|
||||
@if($setting->type !== 'image_path')
|
||||
<div class="{{ $setting->type === 'boolean' ? 'col-md-6 col-lg-6' : 'col-12' }} mb-4">
|
||||
<label class="form-label fw-semibold">
|
||||
{{ str_replace(['lang_en_', 'lang_id_', '_'], ['', '', ' '], $setting->key) }}
|
||||
@if($setting->key === 'app_name')<span class="text-danger">*</span>@endif
|
||||
</label>
|
||||
|
||||
@if($setting->type === 'boolean')
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="{{ $setting->key }}" name="{{ $setting->key }}" value="1" @checked($setting->value === 'true' || $setting->value === '1' || $setting->value === true)>
|
||||
<label class="form-check-label small text-muted ms-2" for="{{ $setting->key }}">{{ __('Enable') }}</label>
|
||||
</div>
|
||||
@elseif(str_ends_with($setting->key, '_at'))
|
||||
<input type="datetime-local" class="form-control" name="{{ $setting->key }}"
|
||||
value="{{ !empty($setting->value) ? date('Y-m-d\TH:i', strtotime($setting->value)) : '' }}">
|
||||
@elseif(str_contains($setting->key, 'color') || str_contains($setting->key, 'background'))
|
||||
<div class="input-group color-picker-container w-100">
|
||||
<span class="input-group-text color-preview-visual"
|
||||
style="background-color: {{ $setting->value ?? '#000000' }}; width: 50px; border-radius: 50rem 0 0 50rem !important; border-right: none;"></span>
|
||||
<input type="text" class="form-control color-input-field fw-bold text-center"
|
||||
name="{{ $setting->key }}" value="{{ $setting->value }}"
|
||||
placeholder="#000000" style="border-left: none; border-right: none; max-width: 110px; background: #f8fafc; font-size: 0.85rem; z-index: 2;">
|
||||
<div class="input-group-text p-0 border-start-0 flex-grow-1 position-relative" style="background: white; border-radius: 0 50rem 50rem 0 !important; overflow: hidden; min-height: 42px;">
|
||||
<input type="color" class="color-picker-sync position-absolute"
|
||||
value="{{ $setting->value ?? '#000000' }}"
|
||||
title="Choose color" style="top: 0; left: 0; width: 100%; height: 100%; cursor: pointer; border: none; padding: 0; opacity: 0; z-index: 1;">
|
||||
<div class="w-100 h-100 color-bar-visual" style="background-color: {{ $setting->value ?? '#000000' }};"></div>
|
||||
</div>
|
||||
</div>
|
||||
@elseif(str_contains($setting->key, 'message') || str_contains($setting->key, 'description') || str_contains($setting->key, 'content') || str_contains($setting->key, 'schedule'))
|
||||
<textarea class="form-control rich-editor" id="editor-{{ $setting->key }}"
|
||||
name="{{ $setting->key }}"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }} here...">{{ $setting->value }}</textarea>
|
||||
@elseif($setting->key === 'announcement_type')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="info" @selected($setting->value === 'info')>Info (Blue)</option>
|
||||
<option value="warning" @selected($setting->value === 'warning')>Warning (Yellow)</option>
|
||||
<option value="danger" @selected($setting->value === 'danger')>Danger (Red)</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'environment_selector')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="production" @selected($setting->value === 'production')>Production</option>
|
||||
<option value="staging" @selected($setting->value === 'staging')>Staging</option>
|
||||
<option value="development" @selected($setting->value === 'development')>Development</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'biometric_auth_type')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="any" @selected($setting->value === 'any')>Any (Fingerprint/Face)</option>
|
||||
<option value="biometrics" @selected($setting->value === 'biometrics')>Biometrics Only</option>
|
||||
<option value="passcode" @selected($setting->value === 'passcode')>Passcode Only</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'log_level')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="debug" @selected($setting->value === 'debug')>Debug</option>
|
||||
<option value="info" @selected($setting->value === 'info')>Info</option>
|
||||
<option value="warn" @selected($setting->value === 'warn')>Warning</option>
|
||||
<option value="error" @selected($setting->value === 'error')>Error</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'priority_level')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="high" @selected($setting->value === 'high')>High</option>
|
||||
<option value="normal" @selected($setting->value === 'normal')>Normal</option>
|
||||
<option value="low" @selected($setting->value === 'low')>Low</option>
|
||||
</select>
|
||||
@elseif($setting->key === 'default_locale')
|
||||
<select class="form-select" name="{{ $setting->key }}">
|
||||
<option value="en" @selected($setting->value === 'en')>English (EN)</option>
|
||||
<option value="id" @selected($setting->value === 'id')>Indonesian (ID)</option>
|
||||
</select>
|
||||
@elseif($setting->type === 'integer' || $setting->type === 'int')
|
||||
<input type="number" class="form-control" name="{{ $setting->key }}" value="{{ $setting->value }}"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }}...">
|
||||
@elseif(str_contains($setting->key, 'message') || str_contains($setting->key, 'text') || str_contains($setting->key, 'subtitle'))
|
||||
<textarea class="form-control" name="{{ $setting->key }}" rows="2"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }}...">{{ $setting->value }}</textarea>
|
||||
@else
|
||||
<input type="text" class="form-control" name="{{ $setting->key }}" value="{{ $setting->value }}"
|
||||
placeholder="Enter {{ str_replace('_', ' ', $setting->key) }}...">
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@foreach($groupSettings as $setting)
|
||||
@if($setting->type === 'image_path')
|
||||
<div class="col-12 mb-5">
|
||||
<label class="form-label fw-semibold text-capitalize">
|
||||
{{ str_replace(['_url', '_'], ['', ' '], $setting->key) }}
|
||||
</label>
|
||||
|
||||
<div class="asset-preview-container shadow-sm mb-3" id="preview-container-{{ $setting->key }}">
|
||||
@php
|
||||
$previewUrl = $setting->value;
|
||||
if ($previewUrl && !Str::startsWith($previewUrl, ['http', 'https', 'data:'])) {
|
||||
$previewUrl = asset($previewUrl);
|
||||
}
|
||||
@endphp
|
||||
@if($previewUrl)
|
||||
<img src="{{ $previewUrl }}" id="img-preview-{{ $setting->key }}" alt="Preview" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;" loading="lazy">
|
||||
@else
|
||||
<i class="bi bi-image text-muted opacity-25 fs-1" id="icon-preview-{{ $setting->key }}"></i>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<input type="file" id="pond-{{ $setting->key }}" name="{{ $setting->key }}"
|
||||
class="filepond-mobile" data-key="{{ $setting->key }}"
|
||||
accept="image/png,image/jpeg,image/webp">
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcantab
|
||||
@endforeach
|
||||
|
||||
<!-- Developer Console Tab Content -->
|
||||
@cantab('mobile settings', 'developer')
|
||||
<div class="tab-pane fade" id="developer" role="tabpanel">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar avatar-40 rounded-circle bg-dark text-white me-3">
|
||||
<i class="bi bi-code-slash fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0 text-uppercase tracking-wider text-dark">{{ __('Sync Payload Preview') }}</h6>
|
||||
<small class="text-muted">{{ __('Raw JSON data being synchronized to mobile clients.') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-dark rounded-pill px-3" onclick="copyConfigJson()">
|
||||
<i class="bi bi-file-earmark-code me-1"></i> {{ __('Copy JSON') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="terminal-box shadow-lg">
|
||||
<div class="terminal-header">
|
||||
<div class="window-controls">
|
||||
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||
</div>
|
||||
<span class="extra-small text-white-50 opacity-50 font-monospace">SYNC_PAYLOAD.JSON</span>
|
||||
<i class="bi bi-braces text-white-50"></i>
|
||||
</div>
|
||||
<div class="terminal-content">
|
||||
<pre id="json-viewer" class="scroll-custom" style="max-height: 500px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">@json(app(\App\Services\MobileConfig\MobileConfigService::class)->all(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4 border-0 bg-info-subtle rounded-4">
|
||||
<div class="d-flex">
|
||||
<i class="bi bi-lightbulb-fill fs-4 me-3"></i>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-1">{{ __('Developer Tip') }}</h6>
|
||||
<p class="small mb-0 opacity-75">
|
||||
Use this JSON to mock responses during mobile development or to verify that all system fallbacks (like branding) are working correctly before release.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endcantab
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manage mobile settings')
|
||||
<div class="sticky-bottom-bar d-flex justify-content-between align-items-center">
|
||||
<div class="small text-muted d-none d-md-block">
|
||||
<i class="bi bi-shield-check me-1"></i> Changes will be synchronized to all mobile clients.
|
||||
</div>
|
||||
<button type="submit" class="btn btn-save-floating px-5">
|
||||
<i class="bi bi-cloud-arrow-up me-2"></i> {{ __('Save Configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Sticky Mobile Mockup -->
|
||||
<div class="col-xl-4 col-lg-5 d-none d-lg-block">
|
||||
<div class="sticky-top" style="top: 100px; z-index: 5;">
|
||||
<div class="text-center mb-3">
|
||||
<span class="badge bg-primary-subtle text-primary rounded-pill px-3 py-2">
|
||||
<i class="bi bi-phone me-1"></i> {{ __('Live App Preview') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- iPhone Mockup Shell -->
|
||||
<div class="mobile-mockup-container mx-auto">
|
||||
<div class="mobile-frame">
|
||||
<div class="mobile-screen bg-light">
|
||||
<!-- Status Bar -->
|
||||
<div class="mobile-status-bar d-flex justify-content-between px-3 pt-2 small text-dark opacity-75">
|
||||
<span class="fw-bold">9:41</span>
|
||||
<div class="d-flex gap-1">
|
||||
<i class="bi bi-reception-4"></i>
|
||||
<i class="bi bi-wifi"></i>
|
||||
<i class="bi bi-battery-full"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic App Content -->
|
||||
<div class="app-content-preview d-flex flex-column align-items-center justify-content-center h-100 p-4 text-center">
|
||||
<div class="app-logo-preview mb-3 animate__animated animate__zoomIn">
|
||||
@php
|
||||
$logo = $settings['branding']->firstWhere('key', 'logo_url')->value ?? '';
|
||||
if ($logo && !Str::startsWith($logo, ['http', 'https', 'data:'])) $logo = asset($logo);
|
||||
@endphp
|
||||
<img src="{{ $logo ?: asset('assets/img/logo-placeholder.png') }}" id="mockup-logo" style="max-height: 80px; object-fit: contain;">
|
||||
</div>
|
||||
<h4 class="fw-black mb-1 mockup-app-name" style="color: #1a1a1a;">{{ $settings['branding']->firstWhere('key', 'app_name')->value ?? 'biiproject' }}</h4>
|
||||
<p class="text-muted small mockup-app-tagline mb-4 px-2">{{ $settings['branding']->firstWhere('key', 'app_tagline')->value ?? 'Smart Solution' }}</p>
|
||||
|
||||
<div class="w-100 px-3">
|
||||
<div class="btn w-100 rounded-pill mb-2 py-2 mockup-primary-bg text-white shadow-sm" style="background-color: {{ $settings['branding']->firstWhere('key', 'theme_color_primary')->value ?? '#C6F135' }}; border: none;">
|
||||
{{ __('Sign In') }}
|
||||
</div>
|
||||
<div class="btn w-100 rounded-pill py-2 border shadow-sm bg-white small">
|
||||
{{ __('Create Account') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto w-100 pt-4">
|
||||
<div class="d-flex justify-content-between x-small text-muted op-75 px-2">
|
||||
<span>v{{ $settings['app_updates']->firstWhere('key', 'app_version')->value ?? '2.0.0' }}</span>
|
||||
<span>{{ __('Secure Connection') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-home-indicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('pages.mobile-settings.scripts')
|
||||
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,214 @@
|
||||
@push('scripts')
|
||||
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/filepond/dist/filepond.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
/**
|
||||
* 1. TAB PERSISTENCE
|
||||
*/
|
||||
(function () {
|
||||
const STORAGE_KEY = 'mobileConfigActiveTab';
|
||||
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(function (tabEl) {
|
||||
tabEl.addEventListener('shown.bs.tab', e => {
|
||||
localStorage.setItem(STORAGE_KEY, e.target.getAttribute('data-bs-target'));
|
||||
});
|
||||
});
|
||||
|
||||
const savedTab = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedTab) {
|
||||
const tabEl = document.querySelector(`[data-bs-target="${savedTab}"]`);
|
||||
if (tabEl) {
|
||||
setTimeout(() => {
|
||||
bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* 2. RICH EDITORS & FILEPOND
|
||||
*/
|
||||
$('.rich-editor').each(function () {
|
||||
ClassicEditor.create(this, {
|
||||
toolbar: ['undo', 'redo', '|', 'heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList']
|
||||
}).catch(e => console.error(e));
|
||||
});
|
||||
|
||||
FilePond.registerPlugin(FilePondPluginImagePreview);
|
||||
const ponds = {};
|
||||
$('.filepond-mobile').each(function () {
|
||||
const key = $(this).data('key');
|
||||
ponds[key] = FilePond.create(this, {
|
||||
labelIdle: 'Drop file or <span class="filepond--label-action">Browse</span>',
|
||||
imagePreviewHeight: 150,
|
||||
stylePanelLayout: 'compact',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 3. FORM HANDLING (SAVE CONFIG)
|
||||
*/
|
||||
$('#mobileConfigForm').on('ajaxForm:beforeSend', function (e, formData) {
|
||||
Object.keys(ponds).forEach(key => {
|
||||
const files = ponds[key].getFiles();
|
||||
if (files.length > 0) formData.set(key, files[0].file);
|
||||
});
|
||||
});
|
||||
|
||||
$('#mobileConfigForm').on('ajaxForm:success', function (e, response) {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Success!') }}",
|
||||
text: response.message,
|
||||
icon: 'success',
|
||||
showConfirmButton: false,
|
||||
timer: 2000,
|
||||
timerProgressBar: true
|
||||
});
|
||||
|
||||
if (response.settings) {
|
||||
Object.keys(response.settings).forEach(key => {
|
||||
const value = response.settings[key];
|
||||
const $previewContainer = $(`#preview-container-${key}`);
|
||||
const $img = $(`#img-preview-${key}`);
|
||||
|
||||
if (value && (key.includes('url') || key.includes('image'))) {
|
||||
const bust = '?v=' + Date.now();
|
||||
const finalUrl = (value.startsWith('http') ? value : ('/' + value.replace(/^\//, ''))) + bust;
|
||||
|
||||
if ($img.length) {
|
||||
$img.attr('src', finalUrl);
|
||||
} else if ($previewContainer.length) {
|
||||
$previewContainer.html(`<img src="${finalUrl}" id="img-preview-${key}" alt="Preview" class="img-fluid rounded" style="max-height: 120px; object-fit: contain;">`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Object.keys(ponds).forEach(key => ponds[key].removeFiles());
|
||||
});
|
||||
|
||||
/**
|
||||
* 4. COLOR PICKER SYNC
|
||||
*/
|
||||
$(document).on('input', '.color-picker-sync', function () {
|
||||
const color = $(this).val();
|
||||
const $container = $(this).closest('.input-group');
|
||||
$container.find('.color-preview-visual').css('background-color', color);
|
||||
$container.find('.color-bar-visual').css('background-color', color);
|
||||
$container.find('.color-input-field').val(color.toUpperCase());
|
||||
});
|
||||
|
||||
$(document).on('input', '.color-input-field', function () {
|
||||
let color = $(this).val().trim();
|
||||
if (color && !color.startsWith('#')) {
|
||||
color = '#' + color;
|
||||
$(this).val(color);
|
||||
}
|
||||
if (/^#[0-9A-F]{3,6}$/i.test(color)) {
|
||||
const $container = $(this).closest('.input-group');
|
||||
$container.find('.color-preview-visual').css('background-color', color);
|
||||
$container.find('.color-bar-visual').css('background-color', color);
|
||||
$container.find('.color-picker-sync').val(color);
|
||||
}
|
||||
});
|
||||
/**
|
||||
* 5. SMART SEARCH ENGINE (TAB FILTER ONLY)
|
||||
*/
|
||||
$('#mobileSearch').on('input', function () {
|
||||
const query = $(this).val().toLowerCase();
|
||||
const $navItems = $('#mobileTabs .nav-item');
|
||||
const $tabPanes = $('.tab-pane');
|
||||
|
||||
if (query === '') {
|
||||
$navItems.show();
|
||||
$tabPanes.find('.mb-3, .mb-4, .row > div, hr, h6').show(); // Reset visibility
|
||||
return;
|
||||
}
|
||||
|
||||
let firstMatchingTabId = null;
|
||||
let currentTabHasMatch = false;
|
||||
const activeTabId = $('.tab-pane.active').attr('id');
|
||||
|
||||
// Reset all content visibility inside panes to "Full"
|
||||
$tabPanes.find('.mb-3, .mb-4, hr, h6, .row > div').show();
|
||||
|
||||
$tabPanes.each(function () {
|
||||
const $pane = $(this);
|
||||
const paneId = $pane.attr('id');
|
||||
let paneHasMatch = false;
|
||||
|
||||
// Check if this tab contains the query anywhere
|
||||
const paneText = $pane.text().toLowerCase();
|
||||
if (paneText.includes(query)) {
|
||||
paneHasMatch = true;
|
||||
}
|
||||
|
||||
// Update Nav Tabs visibility
|
||||
const $navLink = $(`button[data-bs-target="#${paneId}"]`);
|
||||
if (paneHasMatch) {
|
||||
$navLink.parent('.nav-item').show();
|
||||
if (!firstMatchingTabId) firstMatchingTabId = paneId;
|
||||
if (paneId === activeTabId) currentTabHasMatch = true;
|
||||
} else {
|
||||
$navLink.parent('.nav-item').hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Pivot to the first matching tab if current one doesn't match
|
||||
if (!currentTabHasMatch && firstMatchingTabId) {
|
||||
$(`#${firstMatchingTabId}-tab`).tab('show');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 6. LIVE MOCKUP SYNC
|
||||
*/
|
||||
// Sync Text Fields
|
||||
$('input[name="app_name"]').on('input', function () { $('.mockup-app-name').text($(this).val() || 'biiproject'); });
|
||||
$('textarea[name="app_tagline"]').on('input', function () { $('.mockup-app-tagline').text($(this).val() || 'Smart Solution'); });
|
||||
$('input[name="app_version"]').on('input', function () { $('.mockup-version').text('v' + $(this).val()); });
|
||||
|
||||
// Sync Primary Color
|
||||
$(document).on('input', 'input[name="theme_color_primary"], .color-picker-sync', function () {
|
||||
const color = $(this).val();
|
||||
$('.mockup-primary-bg').css('background-color', color);
|
||||
});
|
||||
|
||||
// Sync Logo from FilePond
|
||||
Object.keys(ponds).forEach(key => {
|
||||
if (key === 'logo_url') {
|
||||
ponds[key].on('addfile', (error, file) => {
|
||||
if (!error) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
$('#mockup-logo').attr('src', e.target.result).addClass('animate__animated animate__pulse');
|
||||
setTimeout(() => $('#mockup-logo').removeClass('animate__animated animate__pulse'), 1000);
|
||||
};
|
||||
reader.readAsDataURL(file.file);
|
||||
}
|
||||
});
|
||||
|
||||
ponds[key].on('removefile', () => {
|
||||
// Revert to original or placeholder if needed
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Copy JSON payload to clipboard
|
||||
*/
|
||||
function copyConfigJson() {
|
||||
const jsonText = document.getElementById('json-viewer').textContent;
|
||||
navigator.clipboard.writeText(jsonText).then(() => {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Copied!') }}",
|
||||
text: "{{ __('JSON payload has been copied to clipboard.') }}",
|
||||
icon: 'success',
|
||||
timer: 1500,
|
||||
showConfirmButton: false
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,146 @@
|
||||
<x-guest-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.legal-container {
|
||||
max-width: 1000px;
|
||||
margin: 60px auto;
|
||||
padding: 0 25px;
|
||||
}
|
||||
.legal-header {
|
||||
margin-bottom: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.legal-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 30px 60px rgba(0,0,0,0.1);
|
||||
padding: 60px;
|
||||
}
|
||||
.last-revised-badge {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 50px;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.85rem;
|
||||
display: inline-block;
|
||||
margin-bottom: 15px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.legal-content {
|
||||
font-family: var(--adminuiux-content-font), "Open Sans", sans-serif;
|
||||
line-height: 1.8;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.legal-content h1 {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
line-height: 1.1;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.legal-content h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
}
|
||||
.legal-content h1, .legal-content h2, .legal-content h3 {
|
||||
font-family: var(--adminuiux-title-font), "Outfit", sans-serif;
|
||||
font-weight: 700;
|
||||
margin-top: 45px;
|
||||
margin-bottom: 25px;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.legal-content p { margin-bottom: 20px; }
|
||||
.legal-content ul, .legal-content ol { margin-bottom: 25px; padding-left: 20px; }
|
||||
.legal-content li { margin-bottom: 10px; }
|
||||
.legal-back-btn {
|
||||
font-family: var(--adminuiux-title-font), "Outfit", sans-serif;
|
||||
font-weight: 600;
|
||||
color: #ffffff; /* High contrast on dark bg */
|
||||
background: rgba(0,0,0,0.15);
|
||||
padding: 10px 25px;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(5px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.legal-back-btn:hover {
|
||||
transform: translateX(-5px);
|
||||
background: rgba(0,0,0,0.3);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.legal-footer {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #f1f3f5;
|
||||
}
|
||||
.toc-floating {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
height: fit-content;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.legal-card { padding: 40px; }
|
||||
.legal-container { margin: 30px auto; padding: 0 15px; }
|
||||
}
|
||||
@media (max-width: 575px) {
|
||||
.legal-card { padding: 30px 20px; border-radius: 30px; }
|
||||
.legal-header { margin-bottom: 35px; }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="legal-container">
|
||||
<a href="{{ route('login') }}" class="legal-back-btn">
|
||||
<i class="bi bi-arrow-left me-2"></i> {{ __('Back to Login') }}
|
||||
</a>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="legal-card overflow-hidden">
|
||||
<header class="legal-header">
|
||||
<div class="last-revised-badge">
|
||||
<i class="bi bi-clock-history me-2"></i>
|
||||
{{ __('Version') }} {{ $version }} •
|
||||
{{ __('Last Updated') }}: {{ $lastUpdated ?? now()->format('Y-m-d') }}
|
||||
</div>
|
||||
<h1 class="display-5 fw-bold text-dark">{{ $title }}</h1>
|
||||
</header>
|
||||
|
||||
<article class="legal-content">
|
||||
@if(!empty($content))
|
||||
{!! strip_tags($content, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><u><s><ul><ol><li><a><blockquote><table><thead><tbody><tr><th><td><hr><span><div><img>') !!}
|
||||
@else
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-file-earmark-text fs-1 mb-3 d-block"></i>
|
||||
<p>{{ __('Content for this page has not been published yet.') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($type === 'privacy' && !empty($dpo_email))
|
||||
<div class="alert alert-light border rounded-4 mt-5 p-4">
|
||||
<h6 class="fw-bold"><i class="bi bi-shield-check text-success me-2"></i> {{ __('Data Protection Officer') }}</h6>
|
||||
<p class="small mb-2">{{ __('If you have questions regarding your data privacy, please contact our DPO:') }}</p>
|
||||
<a href="mailto:{{ $dpo_email }}" class="text-primary fw-bold">{{ $dpo_email }}</a>
|
||||
@if(!empty($company_address))
|
||||
<div class="mt-3 x-small text-muted">
|
||||
<i class="bi bi-geo-alt me-1"></i> {{ $company_address }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
<footer class="legal-footer">
|
||||
<p class="small text-muted mb-0">© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
@@ -0,0 +1,150 @@
|
||||
<x-guest-layout :maxWidthClass="'maxwidth-800'">
|
||||
@push('styles')
|
||||
<style>
|
||||
.re-agree-container {
|
||||
width: 100%;
|
||||
margin: 40px auto;
|
||||
}
|
||||
.re-agree-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
border-radius: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 40px 80px rgba(0,0,0,0.1);
|
||||
padding: 50px;
|
||||
}
|
||||
.legal-preview-box {
|
||||
height: 250px;
|
||||
overflow-y: auto;
|
||||
background: rgba(248, 249, 250, 0.8);
|
||||
border: 1px solid rgba(0,0,0,0.05);
|
||||
border-radius: 20px;
|
||||
padding: 25px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
font-family: var(--adminuiux-content-font), sans-serif;
|
||||
}
|
||||
.legal-preview-box h1, .legal-preview-box h2 {
|
||||
font-family: var(--adminuiux-title-font);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: #000;
|
||||
}
|
||||
.step-badge {
|
||||
background: #1e1e1e;
|
||||
color: #fff;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.maxwidth-800 {
|
||||
max-width: 800px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.form-check.custom-check {
|
||||
display: flex;
|
||||
align-items: center; /* Center the checkbox vertically with the text */
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
.form-check.custom-check .form-check-input {
|
||||
margin-top: 0 !important;
|
||||
margin-left: -0.75rem !important;
|
||||
}
|
||||
.btn-pill-primary {
|
||||
white-space: normal !important; /* Allow text to wrap if very long */
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="re-agree-container">
|
||||
<div class="re-agree-card">
|
||||
<div class="text-center mb-5">
|
||||
<div class="avatar avatar-80 rounded-circle bg-primary-subtle text-primary mx-auto mb-4">
|
||||
<i class="bi bi-shield-check fs-1"></i>
|
||||
</div>
|
||||
<h2 class="fw-bold text-dark mb-2" style="font-family: var(--adminuiux-title-font); font-size: clamp(1.5rem, 4vw, 2.25rem);">{{ __('Legal Update Required') }}</h2>
|
||||
<p class="text-muted mx-auto" style="max-width: 500px;">{{ __('We have updated our legal documents to improve our services and compliance with UU PDP regulations. please review and accept the latest terms to continue.') }}</p>
|
||||
</div>
|
||||
|
||||
<form action="{{ route('legal.re-agree.post') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
@if($missingTos)
|
||||
<div class="mb-5">
|
||||
<h5 class="fw-bold mb-4 d-flex align-items-center">
|
||||
<span class="step-badge">1</span> {{ __('Terms of Use Update') }}
|
||||
<span class="badge bg-light text-dark border ms-2 fw-normal fs-xs">v{{ $tosVersion }}</span>
|
||||
</h5>
|
||||
<div class="legal-preview-box">
|
||||
@if(!empty($tosContent))
|
||||
{!! strip_tags($tosContent, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><u><s><ul><ol><li><a><blockquote><table><thead><tbody><tr><th><td><hr><span><div><img>') !!}
|
||||
@else
|
||||
<div class="text-center py-5 opacity-50 italic">
|
||||
<i class="bi bi-file-earmark-text fs-2 mb-2 d-block"></i>
|
||||
{{ __('Terms of Use content is being updated...') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-check custom-check p-3 bg-light rounded-4 border">
|
||||
<input type="checkbox" name="agree_tos" class="form-check-input flex-shrink-0" id="agree_tos" required>
|
||||
<label class="form-check-label fw-bold ms-2 mb-0" for="agree_tos" style="cursor: pointer;">
|
||||
{{ __('I have read and agree to the latest Terms of Use') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($missingPrivacy)
|
||||
<div class="mb-5">
|
||||
<h5 class="fw-bold mb-4 d-flex align-items-center">
|
||||
<span class="step-badge">{{ $missingTos ? '2' : '1' }}</span> {{ __('Privacy Policy Update') }}
|
||||
<span class="badge bg-light text-dark border ms-2 fw-normal fs-xs">v{{ $privacyVersion }}</span>
|
||||
</h5>
|
||||
<div class="legal-preview-box">
|
||||
@if(!empty($privacyContent))
|
||||
{!! strip_tags($privacyContent, '<h1><h2><h3><h4><h5><h6><p><br><strong><em><u><s><ul><ol><li><a><blockquote><table><thead><tbody><tr><th><td><hr><span><div><img>') !!}
|
||||
@else
|
||||
<div class="text-center py-5 opacity-50 italic">
|
||||
<i class="bi bi-shield-lock fs-2 mb-2 d-block"></i>
|
||||
{{ __('Privacy Policy content is being updated...') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="form-check custom-check p-3 bg-light rounded-4 border">
|
||||
<input type="checkbox" name="agree_privacy" class="form-check-input flex-shrink-0" id="agree_privacy" required>
|
||||
<label class="form-check-label fw-bold ms-2 mb-0" for="agree_privacy" style="cursor: pointer;">
|
||||
{{ __('I have read and agree to the latest Privacy Policy (UU PDP)') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-5">
|
||||
<button type="submit" class="btn btn-pill-primary w-100 py-3 fs-5 shadow-sm">
|
||||
{{ __('Update Agreements & Proceed') }} <i class="bi bi-arrow-right ms-2"></i>
|
||||
</button>
|
||||
<a href="{{ route('logout') }}" onclick="event.preventDefault(); document.getElementById('logout-form').submit();" class="btn btn-link text-muted small w-100 mt-4 text-decoration-none">
|
||||
{{ __('Logout and review later') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="logout-form" action="{{ route('logout') }}" method="POST" class="d-none">
|
||||
@csrf
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</x-guest-layout>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,851 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.status-widget-dark {
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%);
|
||||
padding: 1.5rem;
|
||||
color: white;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-premium-action {
|
||||
border-radius: 100px;
|
||||
padding: 10px 24px;
|
||||
font-weight: 700;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.table-premium thead th {
|
||||
background: #f8fafc;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
color: #64748b;
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.table-premium tbody td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.custom-switch-premium .form-check-input {
|
||||
width: 3rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes pulse-danger {
|
||||
0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); }
|
||||
}
|
||||
|
||||
.pulse-danger {
|
||||
animation: pulse-danger 2s infinite;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-5">
|
||||
{{-- Premium Page Header --}}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 animate__animated animate__fadeIn">
|
||||
<div>
|
||||
<h4 class="fw-bold mb-1" style="font-family: 'Outfit', sans-serif; letter-spacing: -0.5px;">
|
||||
{{ __('Backup & Storage') }}
|
||||
</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ __('Securely manage your system archives and cloud synchronization.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-dark btn-premium-action shadow-sm d-flex align-items-center gap-2"
|
||||
data-bs-toggle="modal" data-bs-target="#backupDocsModal">
|
||||
<i class="bi bi-book"></i> {{ __('Documentation') }}
|
||||
</button>
|
||||
@can('manage backup and storage')
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-premium-action shadow-sm d-flex align-items-center gap-2"
|
||||
id="btn-create-backup">
|
||||
<i class="bi bi-plus-lg"></i> {{ __('Instant Backup') }}
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row gx-4">
|
||||
{{-- Left Side: Control Center --}}
|
||||
<div class="col-lg-4 animate__animated animate__fadeIn">
|
||||
|
||||
{{-- Storage Health Widget (Dark Premium) --}}
|
||||
<div id="storage-health-widget" class="status-widget-dark" style="display:none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-white bg-opacity-10 rounded-circle p-2">
|
||||
<i class="bi bi-cloud-check text-info"></i>
|
||||
</div>
|
||||
<span class="small fw-bold opacity-75 text-uppercase tracking-wider"
|
||||
id="health-label">Storage</span>
|
||||
</div>
|
||||
<span class="badge rounded-pill bg-info text-dark fw-bold px-3 py-2" id="health-status"
|
||||
style="font-size: 10px;">Checking...</span>
|
||||
</div>
|
||||
|
||||
<h2 class="mb-1 fw-bold" id="health-used" style="font-family: 'Outfit', sans-serif;">0</h2>
|
||||
<p class="small opacity-50 mb-4" id="health-total-label">of <span id="health-total">0</span> used
|
||||
</p>
|
||||
|
||||
<div class="progress bg-white bg-opacity-10 mb-2" style="height: 8px; border-radius: 10px;"
|
||||
id="health-progress-wrapper">
|
||||
<div class="progress-bar bg-info" id="health-progress-bar" role="progressbar"
|
||||
style="width: 0%; border-radius: 10px;"></div>
|
||||
</div>
|
||||
|
||||
{{-- Requirements Alert --}}
|
||||
<div id="requirements-alert"
|
||||
class="mt-3 p-2 rounded-3 bg-danger bg-opacity-25 border border-danger border-opacity-25 small"
|
||||
style="display:none;">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i> <span id="req-msg">Check reqs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="backupConfigForm" action="{{ route('system-config.update') }}" method="POST"
|
||||
autocomplete="off" class="ajax-form" data-reset="false">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Automation Card --}}
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-clock-history text-primary"></i>
|
||||
{{ __('Automation Settings') }}
|
||||
</h6>
|
||||
|
||||
<div
|
||||
class="form-check form-switch custom-switch-premium mb-4 p-0 d-flex align-items-center justify-content-between">
|
||||
<label class="form-check-label fw-semibold text-dark"
|
||||
for="backup_db_enabled">{{ __('AUTO BACKUP') }}</label>
|
||||
<input class="form-check-input ms-0" type="checkbox" role="switch"
|
||||
id="backup_db_enabled" name="backup_db_enabled" value="1"
|
||||
@checked(old('backup_db_enabled', $settings['backup_db_enabled'] ?? true))>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Backup Frequency') }}</label>
|
||||
<select class="form-select" name="backup_db_frequency">
|
||||
<option value="hourly" @selected(($settings['backup_db_frequency'] ?? '') == 'hourly')>{{ __('Hourly') }}</option>
|
||||
<option value="daily" @selected(($settings['backup_db_frequency'] ?? 'daily') == 'daily')>{{ __('Daily') }}</option>
|
||||
<option value="weekly" @selected(($settings['backup_db_frequency'] ?? '') == 'weekly')>{{ __('Weekly') }}</option>
|
||||
<option value="monthly" @selected(($settings['backup_db_frequency'] ?? '') == 'monthly')>{{ __('Monthly') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">{{ __('Execution Time') }}</label>
|
||||
<input type="time" class="form-control" name="backup_db_time"
|
||||
value="{{ $settings['backup_db_time'] ?? '02:00' }}">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">{{ __('Retention (Days)') }}</label>
|
||||
<input type="number" class="form-control text-center" name="backup_db_retention"
|
||||
value="{{ $settings['backup_db_retention'] ?? 7 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Storage Destination Card --}}
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-hdd-network text-primary"></i>
|
||||
{{ __('Storage Target') }}
|
||||
</h6>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold mb-2">{{ __('Primary Driver') }}</label>
|
||||
<select class="form-select" name="backup_db_driver" id="backup_db_driver">
|
||||
<option value="local" @selected(($settings['backup_db_driver'] ?? 'local') == 'local')>Local Filesystem</option>
|
||||
<option value="gdrive" @selected(($settings['backup_db_driver'] ?? '') == 'gdrive')>
|
||||
Google Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{{-- Cloud Fields Wrapper --}}
|
||||
<div id="cloud_config_wrapper"
|
||||
style="display: {{ ($settings['backup_db_driver'] ?? '') == 'gdrive' ? 'block' : 'none' }}">
|
||||
<div class="p-3 rounded-4 bg-light border border-opacity-10 mb-4">
|
||||
{{-- Google Drive --}}
|
||||
<div id="gdrive_fields"
|
||||
style="display: {{ ($settings['backup_db_driver'] ?? '') == 'gdrive' ? 'block' : 'none' }}">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-google text-primary"></i>
|
||||
<h6 class="fw-semibold mb-0 small">{{ __('Google Cloud Auth') }}</h6>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm rounded-pill px-3 bg-white"
|
||||
name="gdrive_client_id"
|
||||
value="{{ $settings['gdrive_client_id'] ?? '' }}"
|
||||
placeholder="Client ID">
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="password"
|
||||
class="form-control border-end-0 rounded-start-pill px-3 bg-white"
|
||||
name="gdrive_client_secret"
|
||||
value="{{ $settings['gdrive_client_secret'] ?? '' }}"
|
||||
placeholder="Client Secret">
|
||||
<button
|
||||
class="btn btn-outline-secondary bg-white border-start-0 password-toggle rounded-end-pill pe-3"
|
||||
type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="password"
|
||||
class="form-control border-0 rounded-start-pill px-3 bg-white"
|
||||
name="gdrive_refresh_token"
|
||||
value="{{ $settings['gdrive_refresh_token'] ?? '' }}"
|
||||
placeholder="Refresh Token">
|
||||
<button class="btn btn-outline-secondary bg-white border-0 password-toggle"
|
||||
type="button">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
<a href="{{ route('backup-restore.google-auth') }}"
|
||||
class="btn btn-dark rounded-end-pill px-3" id="btn-gdrive-auth">
|
||||
<i class="bi bi-key"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text"
|
||||
class="form-control border-0 rounded-start-pill px-3 bg-white"
|
||||
name="gdrive_folder"
|
||||
value="{{ $settings['gdrive_folder'] ?? 'LaravelBackups' }}"
|
||||
placeholder="Folder Name">
|
||||
<button type="button"
|
||||
class="btn btn-primary rounded-end-pill px-3 btn-test-connection"
|
||||
data-driver="gdrive">
|
||||
<i class="bi bi-broadcast"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manage backup and storage')
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">
|
||||
{{ __('Save Configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Right Side: History (Col-8) --}}
|
||||
<div class="col-lg-8 animate__animated animate__fadeIn">
|
||||
<div class="card adminuiux-card h-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-4 d-flex justify-content-between align-items-center border-bottom bg-white sticky-top"
|
||||
style="z-index: 10; border-top-left-radius: 20px; border-top-right-radius: 20px;">
|
||||
<h6 class="fw-bold mb-0 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-collection-play text-primary"></i>
|
||||
{{ __('Archive Inventory') }}
|
||||
</h6>
|
||||
<button type="button" class="btn btn-sm btn-light rounded-pill px-3"
|
||||
id="btn-refresh-backups">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i> {{ __('Refresh List') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-premium align-middle mb-0" id="backup-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="ps-4">{{ __('Archive Name') }}</th>
|
||||
<th>{{ __('Status') }}</th>
|
||||
<th>{{ __('Timestamp') }}</th>
|
||||
<th class="text-end pe-4">{{ __('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr id="backup-loading">
|
||||
<td colspan="4" class="text-center py-5">
|
||||
<div class="spinner-grow spinner-grow-sm text-primary me-2"></div>
|
||||
<span
|
||||
class="text-muted small fw-bold">{{ __('Scanning Archives...') }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ====================================================================
|
||||
DOCUMENTATION (MODAL)
|
||||
==================================================================== --}}
|
||||
<div class="modal fade" id="backupDocsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable modal-dialog-centered">
|
||||
<div class="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||||
<div class="modal-header p-0 border-0" style="background:#0f172a;">
|
||||
<div class="d-flex w-100 align-items-center justify-content-between p-4 p-lg-5 text-white">
|
||||
<div>
|
||||
<div class="extra-small opacity-50 fw-bold mb-2" style="letter-spacing:1.5px;">REFERENCE GUIDE</div>
|
||||
<h3 class="fw-black mb-2" style="letter-spacing:-1px;">{{ __('Backup & Restore Documentation') }}</h3>
|
||||
<p class="mb-0 opacity-75" style="font-size:.9rem;line-height:1.7;max-width:720px;">
|
||||
Everything you need to know about creating, scheduling, restoring, and securing your system archives.
|
||||
<span class="text-warning fw-bold">Read this first</span> — restoring overwrites your live data.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-shield-fill-check display-3 opacity-25 d-none d-md-inline"></i>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="p-4 p-lg-5">
|
||||
|
||||
{{-- DANGER NOTICE --}}
|
||||
<div class="d-flex align-items-start gap-3 p-3 rounded-4 mb-5" style="background:#fef2f2;border:2px solid #fecaca;">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger fs-2"></i>
|
||||
<div>
|
||||
<div class="fw-black text-danger mb-1">CRITICAL — Restore is destructive</div>
|
||||
<div class="small text-dark" style="line-height:1.7;">
|
||||
Restoring a backup <span class="fw-bold">overwrites every table</span> in your current database with the data from the archive.
|
||||
All records created since that backup was taken will be <span class="fw-bold text-danger">permanently lost</span>.
|
||||
Always create a fresh <span class="fw-bold">Instant Backup</span> immediately before restoring, so you have a return path.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- WORKFLOW STEPS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-diagram-3 text-primary me-1"></i>How a backup is created
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
@php
|
||||
$steps = [
|
||||
['icon'=>'bi-database', 'color'=>'#3b82f6', 'title'=>'1. Snapshot', 'desc'=>'A full SQL dump of every table is generated via <code>mysqldump</code> / <code>pg_dump</code> at the moment you click Instant Backup or when the scheduler fires.'],
|
||||
['icon'=>'bi-file-zip', 'color'=>'#8b5cf6', 'title'=>'2. Compress', 'desc'=>'The SQL dump is gzip-compressed into a single <code>.sql.gz</code> archive named with timestamp + driver tag.'],
|
||||
['icon'=>'bi-hdd-network', 'color'=>'#22c55e', 'title'=>'3. Transfer', 'desc'=>'Archive is written to the configured driver. <span class="fw-bold">Local</span> saves to <code>storage/app/backups/</code>. <span class="fw-bold">Google Drive</span> uploads via OAuth refresh token.'],
|
||||
['icon'=>'bi-clipboard-check','color'=>'#f59e0b', 'title'=>'4. Verify', 'desc'=>'File size and SHA-256 checksum are recorded. The Archive Inventory table reflects the new row immediately.'],
|
||||
['icon'=>'bi-trash3', 'color'=>'#ef4444', 'title'=>'5. Prune', 'desc'=>'Archives older than the configured retention (days) are auto-deleted on the next scheduled run. Local + cloud are pruned independently.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($steps as $s)
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#f8fafc;border:1px solid #e2e8f0;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="rounded-3 d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style="background:{{ $s['color'] }}1a;color:{{ $s['color'] }};width:36px;height:36px;">
|
||||
<i class="{{ $s['icon'] }}"></i>
|
||||
</div>
|
||||
<div class="fw-bold text-dark">{{ $s['title'] }}</div>
|
||||
</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">{!! $s['desc'] !!}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- STORAGE DRIVERS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-hdd-stack text-info me-1"></i>Storage drivers comparison
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">DRIVER</th>
|
||||
<th>WHERE</th>
|
||||
<th>SETUP</th>
|
||||
<th>BEST FOR</th>
|
||||
<th>RISK IF SERVER DIES</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">
|
||||
<i class="bi bi-hdd text-secondary me-1"></i>Local Filesystem
|
||||
</td>
|
||||
<td class="small text-muted"><code>storage/app/backups/</code></td>
|
||||
<td class="small"><span class="badge bg-success-subtle text-success rounded-pill">Zero config</span></td>
|
||||
<td class="small text-muted">Quick recovery, small datasets, dev environments</td>
|
||||
<td class="small text-danger fw-bold">Total loss — backups on same disk</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">
|
||||
<i class="bi bi-google text-primary me-1"></i>Google Drive
|
||||
</td>
|
||||
<td class="small text-muted">OAuth folder (default: <code>LaravelBackups</code>)</td>
|
||||
<td class="small"><span class="badge bg-warning-subtle text-warning rounded-pill">Client ID + Secret + Refresh Token</span></td>
|
||||
<td class="small text-muted">Off-site redundancy, production, disaster recovery</td>
|
||||
<td class="small text-success fw-bold">Safe — off-server copy</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- GOOGLE DRIVE SETUP --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-google text-primary me-1"></i>Google Drive setup · Step-by-step
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="fw-bold text-primary mb-2">1. Create OAuth client</div>
|
||||
<ol class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>Go to <a href="https://console.cloud.google.com/" target="_blank">Google Cloud Console</a></li>
|
||||
<li>Create a project → enable <span class="fw-bold">Google Drive API</span></li>
|
||||
<li>OAuth consent screen → External → fill app name</li>
|
||||
<li>Credentials → Create → OAuth client ID → Web application</li>
|
||||
<li>Add redirect URI: <code>{{ url('/backup-restore/google-callback') }}</code></li>
|
||||
<li>Copy <span class="fw-bold">Client ID</span> & <span class="fw-bold">Client Secret</span></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="fw-bold text-primary mb-2">2. Link this app</div>
|
||||
<ol class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>Paste Client ID + Secret in the form on the left</li>
|
||||
<li>Click the <i class="bi bi-key"></i> key button next to Refresh Token</li>
|
||||
<li>Authorize on the Google consent screen</li>
|
||||
<li>Refresh token is auto-filled on return</li>
|
||||
<li>Click <i class="bi bi-broadcast"></i> to test connection</li>
|
||||
<li>Save configuration</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SCHEDULING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-clock-history text-warning me-1"></i>Automation & scheduling
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Frequency</div>
|
||||
<div class="small text-muted">Hourly / Daily / Weekly / Monthly. The Laravel scheduler must be running (<code>cron</code> calling <code>php artisan schedule:run</code> every minute).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Execution Time</div>
|
||||
<div class="small text-muted">24-hour format (default <code>02:00</code>). Pick low-traffic hours — backup briefly locks tables.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Retention</div>
|
||||
<div class="small text-muted">Days to keep. Older archives auto-pruned. Default <code>7</code>. Set to <code>0</code> to keep forever (not recommended).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="fw-bold text-dark mb-1">Auto Backup toggle</div>
|
||||
<div class="small text-muted">Master switch. When OFF, only manual <span class="fw-bold">Instant Backup</span> runs.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RESTORE PROCEDURE --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-arrow-counterclockwise text-danger me-1"></i>How to restore safely
|
||||
</h6>
|
||||
<div class="p-4 rounded-4 mb-5" style="background:#0f172a;color:#e2e8f0;">
|
||||
<pre class="mb-0" style="font-family:'Fira Code',monospace;font-size:.78rem;line-height:2;white-space:pre-wrap;color:#cbd5e1;">
|
||||
<span style="color:#22c55e;">RECOMMENDED RESTORE SEQUENCE:</span>
|
||||
|
||||
<span style="color:#fbbf24;">[1]</span> Click <span style="color:#22c55e;">Instant Backup</span> NOW — creates a return path
|
||||
<span style="color:#fbbf24;">[2]</span> Enable Maintenance Mode (so users can't write data mid-restore)
|
||||
<span style="color:#fbbf24;">[3]</span> Open Archive Inventory → identify the archive you want
|
||||
<span style="color:#fbbf24;">[4]</span> Click the row action menu → <span style="color:#ef4444;">Restore</span>
|
||||
<span style="color:#fbbf24;">[5]</span> Confirm the dialog (read it carefully — points of no return)
|
||||
<span style="color:#fbbf24;">[6]</span> Wait for the success toast (do NOT navigate away)
|
||||
<span style="color:#fbbf24;">[7]</span> Verify data integrity on a couple of critical tables
|
||||
<span style="color:#fbbf24;">[8]</span> Disable Maintenance Mode
|
||||
|
||||
<span style="color:#94a3b8;"># If something looks wrong after restore:
|
||||
# → Use the safety backup from step [1] to roll back</span></pre>
|
||||
</div>
|
||||
|
||||
{{-- TROUBLESHOOTING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-wrench-adjustable-circle text-secondary me-1"></i>Troubleshooting
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">SYMPTOM</th>
|
||||
<th>LIKELY CAUSE</th>
|
||||
<th>FIX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Backup button does nothing</td>
|
||||
<td class="small text-muted"><code>storage/app/backups/</code> not writable</td>
|
||||
<td class="small text-muted"><code>chmod -R 775 storage/</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">"Requirements not met" alert</td>
|
||||
<td class="small text-muted"><code>mysqldump</code> / <code>pg_dump</code> not in PATH</td>
|
||||
<td class="small text-muted">Install DB client tools on the server</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Scheduled backup never runs</td>
|
||||
<td class="small text-muted">Laravel scheduler cron not registered</td>
|
||||
<td class="small text-muted">Add <code>* * * * * php artisan schedule:run</code> to crontab</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Google Drive upload fails</td>
|
||||
<td class="small text-muted">Refresh token expired or revoked</td>
|
||||
<td class="small text-muted">Re-authorize via the <i class="bi bi-key"></i> key button</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Disk fills up rapidly</td>
|
||||
<td class="small text-muted">Retention too high or hourly schedule on large DB</td>
|
||||
<td class="small text-muted">Lower retention, switch to daily, prune old archives manually</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">Restore hangs / times out</td>
|
||||
<td class="small text-muted">Large archive, low PHP <code>max_execution_time</code></td>
|
||||
<td class="small text-muted">Run restore via CLI: <code>php artisan backup:restore {file}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- BEST PRACTICES --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-stars text-success me-1"></i>Best practices
|
||||
</h6>
|
||||
<div class="row g-2 mb-5">
|
||||
@php
|
||||
$tips = [
|
||||
['icon'=>'bi-cloud-arrow-up', 'title'=>'Use 3-2-1 rule', 'desc'=>'<span class="fw-bold">3</span> copies of data, on <span class="fw-bold">2</span> different storage types, with <span class="fw-bold">1</span> off-site (Google Drive).'],
|
||||
['icon'=>'bi-check2-circle', 'title'=>'Test your restores', 'desc'=>'A backup you have never restored is just hope, not insurance. Test in staging every quarter.'],
|
||||
['icon'=>'bi-clock', 'title'=>'Off-hours schedule', 'desc'=>'Pick a time when traffic is lowest (typically 02:00–04:00 local) to minimise lock contention.'],
|
||||
['icon'=>'bi-shield-lock', 'title'=>'Restrict access', 'desc'=>'Only grant <code>manage backup and storage</code> to senior admins. Restore is one click away from catastrophe.'],
|
||||
['icon'=>'bi-bar-chart-line', 'title'=>'Monitor storage health', 'desc'=>'Watch the dark widget in the sidebar — when it turns red, prune or expand storage.'],
|
||||
['icon'=>'bi-clipboard-pulse', 'title'=>'Document recovery plan', 'desc'=>'Write down (outside this system) the exact steps to restore + who has the Google account credentials.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($tips as $tip)
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<i class="{{ $tip['icon'] }} text-success fs-5 mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ $tip['title'] }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">{!! $tip['desc'] !!}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- PERMISSIONS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-person-badge text-secondary me-1"></i>Permissions
|
||||
</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<div class="fw-bold text-success mb-1"><code class="text-success">view backup and storage</code></div>
|
||||
<div class="small text-muted">Read-only access. Can see archive list and storage health but cannot create / restore / delete.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4" style="background:#fef2f2;border:1px solid #fecaca;">
|
||||
<div class="fw-bold text-danger mb-1"><code class="text-danger">manage backup and storage</code></div>
|
||||
<div class="small text-muted">Full control: create, restore, delete archives + change driver config + edit retention. <span class="fw-bold">Restrict tightly.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- FOOTER NOTE --}}
|
||||
<div class="d-flex align-items-center gap-3 p-3 rounded-4" style="background:#fef3c7;border:1px solid #fde68a;">
|
||||
<i class="bi bi-info-circle-fill text-warning fs-3"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">Where archives are stored on this server</div>
|
||||
<div class="small text-muted">
|
||||
Local backups live at <code>storage/app/backups/</code>. They are <span class="fw-bold">excluded from your code repository</span> by <code>.gitignore</code>. To migrate archives to a new server, copy this directory (and the Google Drive token from Global Settings → Backup).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> {{-- closes p-4 p-lg-5 --}}
|
||||
</div> {{-- closes modal-body --}}
|
||||
</div> {{-- closes modal-content --}}
|
||||
</div> {{-- closes modal-dialog --}}
|
||||
</div> {{-- closes modal --}}
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
function renderBackups(backups) {
|
||||
const $tbody = $('#backup-list-table tbody');
|
||||
$tbody.empty();
|
||||
|
||||
if (!backups || backups.length === 0) {
|
||||
$tbody.append('<tr><td colspan="4" class="text-center py-5 text-muted small fw-bold">{{ __("No system archives found.") }}</td></tr>');
|
||||
return;
|
||||
}
|
||||
|
||||
backups.forEach(function (item) {
|
||||
let storageIcon = 'bi-hdd text-secondary';
|
||||
let storageClass = 'bg-secondary-subtle text-secondary';
|
||||
|
||||
if (item.storage === 'gdrive') {
|
||||
storageIcon = 'bi-google text-primary';
|
||||
storageClass = 'bg-primary-subtle text-primary';
|
||||
} else if (item.storage === 's3') {
|
||||
storageIcon = 'bi-clouds text-warning';
|
||||
storageClass = 'bg-warning-subtle text-warning-emphasis';
|
||||
}
|
||||
|
||||
$tbody.append(`
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar avatar-35 bg-light rounded-circle d-flex align-items-center justify-content-center me-3 border">
|
||||
<i class="bi ${storageIcon}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold text-dark" style="font-size: 0.85rem; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${item.name}">${item.name.split('/').pop()}</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="text-muted" style="font-size: 10px;">${item.size}</span>
|
||||
<span class="badge ${storageClass} border-0 rounded-pill text-uppercase" style="font-size: 8px; padding: 2px 6px;">${item.storage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle rounded-pill px-3 py-1" style="font-size: 10px;">
|
||||
${item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">${item.date}</span>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="d-flex justify-content-end gap-1">
|
||||
<a href="{{ route('backup-restore.download') }}?disk=${item.storage}&path=${item.name}" class="btn btn-icon btn-sm btn-light rounded-circle" title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
@can('manage backup and storage')
|
||||
<button type="button" class="btn btn-icon btn-sm btn-outline-primary rounded-circle btn-restore-backup" data-disk="${item.storage}" data-path="${item.name}" title="Restore">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-icon btn-sm btn-outline-danger rounded-circle btn-delete-backup" data-disk="${item.storage}" data-path="${item.name}" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
@endcan
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
function loadBackups(driver = null) {
|
||||
const $tbody = $('#backup-list-table tbody');
|
||||
$tbody.html('<tr id="backup-loading"><td colspan="4" class="text-center py-5"><div class="spinner-border spinner-border-sm text-primary me-2"></div><span class="small fw-bold">{{ __("Refreshing inventory...") }}</span></td></tr>');
|
||||
|
||||
const url = new URL("{{ route('backup-restore.index') }}", window.location.origin);
|
||||
if (driver) url.searchParams.append('driver', driver);
|
||||
|
||||
$.get(url.toString(), function (response) {
|
||||
if (response.success) {
|
||||
renderBackups(response.backups);
|
||||
if (response.stats) {
|
||||
const stats = response.stats;
|
||||
$('#storage-health-widget').fadeIn();
|
||||
$('#health-label').text(stats.label + " Usage");
|
||||
$('#health-used').text(stats.used);
|
||||
|
||||
$('#health-total').text(stats.total);
|
||||
$('#health-progress-bar').css('width', stats.percentage + '%');
|
||||
$('#health-progress-bar').removeClass('bg-success bg-warning bg-danger').addClass('bg-' + stats.health);
|
||||
$('#health-status').text(stats.percentage + '% Cap').removeClass('text-success text-warning text-danger bg-success-subtle bg-warning-subtle bg-danger-subtle').addClass('text-' + stats.health + ' bg-' + stats.health + '-subtle');
|
||||
|
||||
if (stats.health === 'danger') {
|
||||
$('#storage-health-widget').addClass('pulse-danger border border-danger border-opacity-50');
|
||||
} else {
|
||||
$('#storage-health-widget').removeClass('pulse-danger border border-danger border-opacity-50');
|
||||
}
|
||||
|
||||
$('#health-total-label').show();
|
||||
$('#health-progress-wrapper').show();
|
||||
|
||||
if (stats.requirements && !stats.requirements.status) {
|
||||
$('#requirements-alert').fadeIn();
|
||||
$('#req-msg').text(stats.requirements.message);
|
||||
} else {
|
||||
$('#requirements-alert').hide();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$tbody.html(`<tr><td colspan="4" class="text-center py-5 text-danger small fw-bold">${response.message || '{{ __("Failed to load inventory.") }}'}</td></tr>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadBackups($('#backup_db_driver').val());
|
||||
|
||||
$('#btn-refresh-backups').on('click', function () {
|
||||
const $icon = $(this).find('i');
|
||||
$icon.addClass('animate-spin');
|
||||
loadBackups($('#backup_db_driver').val());
|
||||
setTimeout(() => $icon.removeClass('animate-spin'), 1000);
|
||||
});
|
||||
|
||||
$('#backup_db_driver').on('change', function () {
|
||||
const driver = $(this).val();
|
||||
|
||||
// UI Toggle
|
||||
if (driver === 'gdrive') {
|
||||
$('#cloud_config_wrapper').slideDown();
|
||||
$('#gdrive_fields').fadeIn();
|
||||
} else {
|
||||
$('#cloud_config_wrapper').slideUp();
|
||||
}
|
||||
|
||||
// Dynamic Usage Update
|
||||
loadBackups(driver);
|
||||
});
|
||||
|
||||
$('#btn-create-backup').on('click', function () {
|
||||
const $btn = $(this);
|
||||
const original = $btn.html();
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span> Preparing...');
|
||||
|
||||
$.post("{{ route('backup-restore.create') }}", { _token: "{{ csrf_token() }}" }, function (response) {
|
||||
if (response.success) {
|
||||
renderBackups(response.backups || []);
|
||||
StandardSwal.fire({ title: "Backup Started", text: response.message, icon: 'success', timer: 2000, showConfirmButton: false });
|
||||
} else {
|
||||
StandardSwal.fire({ title: 'Error', text: response.message, icon: 'error' });
|
||||
}
|
||||
}).always(() => $btn.prop('disabled', false).html(original));
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-delete-backup', function () {
|
||||
const $btn = $(this);
|
||||
StandardSwal.fire({
|
||||
title: "Delete Archive?",
|
||||
text: "This file will be permanently removed from storage.",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Delete Now"
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
$.post("{{ route('backup-restore.delete') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
disk: $btn.data('disk'),
|
||||
path: $btn.data('path')
|
||||
}, function (response) {
|
||||
if (response.success) {
|
||||
renderBackups(response.backups);
|
||||
StandardSwal.fire({ title: "Deleted", text: response.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-restore-backup', function () {
|
||||
const $btn = $(this);
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Restore System?') }}",
|
||||
html: `
|
||||
<div class="text-start small">
|
||||
<p class="mb-3">{{ __('This will replace your current database with the selected archive. The system will enter Maintenance Mode during the process.') }}</p>
|
||||
<div class="alert alert-danger border-0 shadow-sm d-flex align-items-center gap-3 mb-0">
|
||||
<i class="bi bi-exclamation-octagon display-6"></i>
|
||||
<div>
|
||||
<strong class="d-block">{{ __('CRITICAL ACTION') }}</strong>
|
||||
{{ __('All current unsaved data will be lost permanently.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('I Understand, Restore Now') }}",
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonText: "{{ __('Cancel') }}"
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('System Restoration In Progress') }}",
|
||||
html: "{{ __('Please wait, do not close this window. The system is currently in Maintenance Mode.') }}",
|
||||
allowOutsideClick: false,
|
||||
didOpen: () => StandardSwal.showLoading()
|
||||
});
|
||||
|
||||
$.post("{{ route('backup-restore.restore') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
disk: $btn.data('disk'),
|
||||
path: $btn.data('path')
|
||||
}, function (response) {
|
||||
StandardSwal.fire({ title: "{{ __('Restored Successfully') }}", text: response.message, icon: 'success', timer: 3000 });
|
||||
setTimeout(() => location.reload(), 3000);
|
||||
}).fail(function(xhr) {
|
||||
const msg = xhr.responseJSON?.message || '{{ __("Restoration failed. System is back online.") }}';
|
||||
StandardSwal.fire({ title: "{{ __('Restoration Failed') }}", text: msg, icon: 'error' });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('.btn-test-connection').on('click', function () {
|
||||
const $btn = $(this);
|
||||
const original = $btn.html();
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span>');
|
||||
$.post("{{ route('backup-restore.test-connection') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
driver: $btn.data('driver')
|
||||
}, function (response) {
|
||||
StandardSwal.fire({ title: response.success ? "Success" : "Failed", text: response.message, icon: response.success ? 'success' : 'error' });
|
||||
}).always(() => $btn.prop('disabled', false).html(original));
|
||||
});
|
||||
|
||||
$('#btn-gdrive-auth').on('click', function (e) {
|
||||
const clientId = $('input[name="gdrive_client_id"]').val();
|
||||
const clientSecret = $('input[name="gdrive_client_secret"]').val();
|
||||
if (!clientId || !clientSecret) {
|
||||
e.preventDefault();
|
||||
StandardSwal.fire({ title: "Auth Failed", text: "Save Client ID & Secret first.", icon: 'warning' });
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,542 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<link href="https://unpkg.com/filepond/dist/filepond.css" rel="stylesheet" />
|
||||
<link href="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css"
|
||||
rel="stylesheet" />
|
||||
<style>
|
||||
/* Shared Styles from Backup & Storage */
|
||||
.status-widget-dark {
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%);
|
||||
padding: 1.5rem;
|
||||
color: white;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Attached Design Preview Styles */
|
||||
.mockup-container {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.browser-mockup-frame {
|
||||
background: #e2e8f0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.browser-top-bar {
|
||||
background: #f8fafc;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.browser-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.browser-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.browser-url-field {
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
padding: 3px 15px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
flex-grow: 1;
|
||||
max-width: 450px;
|
||||
text-align: center;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
/* VISITOR VIEW BACKGROUND (STRICT AS PER IMAGE) */
|
||||
.viewport-preview {
|
||||
height: 700px;
|
||||
background: #f4f4f4;
|
||||
/* Base light gray */
|
||||
background-image: repeating-linear-gradient(45deg, #f0f2f5, #f0f2f5 10px, #ffffff 10px, #ffffff 20px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* WHITE CARD (STRICT AS PER IMAGE) */
|
||||
.visitor-card-premium {
|
||||
background: #ffffff;
|
||||
border-radius: 42px;
|
||||
padding: 4rem 3rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
box-shadow: 0 15px 35px -5px rgba(0, 0, 0, 0.04);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.under-maintenance-pill {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 100px;
|
||||
padding: 8px 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 2.5rem;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #1a1c1e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.pulse-dot-red {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #ef4444;
|
||||
border-radius: 50%;
|
||||
animation: pulse-red 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* COUNTDOWN SQUARES (STRICT AS PER IMAGE) */
|
||||
.countdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.countdown-square {
|
||||
background: #1a1c1e;
|
||||
border-radius: 20px;
|
||||
aspect-ratio: 1/1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.countdown-square:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 7px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.5;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.custom-switch-premium .form-check-input {
|
||||
width: 3rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filepond--root {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filepond--panel-root {
|
||||
background-color: #f8fafc;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-5">
|
||||
{{-- Page Header --}}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 animate__animated animate__fadeIn">
|
||||
<div>
|
||||
<h4 class="fw-bold mb-1" style="font-family: 'Outfit', sans-serif; letter-spacing: -0.5px;">
|
||||
{{ __('Maintenance Mode') }}
|
||||
</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ __('Take your application offline for scheduled updates and optimization.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="maintenanceConfigForm" action="{{ route('system-config.update') }}" method="POST"
|
||||
enctype="multipart/form-data" autocomplete="off" class="ajax-form" data-reset="false">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="row gx-4">
|
||||
{{-- Left: Controls (Col-4) --}}
|
||||
<div class="col-lg-4 animate__animated animate__fadeIn">
|
||||
|
||||
{{-- Status Card (Backup Styling) --}}
|
||||
<div class="status-widget-dark">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-white bg-opacity-10 rounded-circle p-2">
|
||||
<i class="bi bi-broadcast text-info"></i>
|
||||
</div>
|
||||
<span class="small fw-bold opacity-75 text-uppercase tracking-wider">Storage
|
||||
Health</span>
|
||||
</div>
|
||||
@if($is_down)
|
||||
<span class="badge rounded-pill bg-danger text-white fw-bold px-3 py-2"
|
||||
style="font-size: 10px;">Maintenance</span>
|
||||
@else
|
||||
<span class="badge rounded-pill bg-info text-dark fw-bold px-3 py-2"
|
||||
style="font-size: 10px;">Operational</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="mb-1 fw-bold" style="font-family: 'Outfit', sans-serif;">
|
||||
@if($is_down)
|
||||
System Offline
|
||||
@else
|
||||
Systems Ready
|
||||
@endif
|
||||
</h2>
|
||||
<p class="small opacity-50 mb-0">Public access control center.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="form-check form-switch custom-switch-premium p-0 d-flex align-items-center justify-content-between bg-white bg-opacity-5 p-3 rounded-4 border border-white border-opacity-10">
|
||||
<label class="form-check-label fw-semibold small text-white"
|
||||
for="maintenance_mode_enabled">{{ __('ENABLE MODE') }}</label>
|
||||
<input class="form-check-input ms-0" type="checkbox" role="switch"
|
||||
id="maintenance_mode_enabled" name="maintenance_mode_enabled" value="1"
|
||||
@checked(old('maintenance_mode_enabled', $settings['maintenance_mode_enabled'] ?? false))>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Config Sections --}}
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-palette-fill text-primary"></i>
|
||||
{{ __('Visual & Branding') }}
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Main Headline') }}</label>
|
||||
<input type="text" class="form-control" name="maintenance_mode_title"
|
||||
value="{{ $settings['maintenance_mode_title'] ?? 'biiproject.com' }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Description') }}</label>
|
||||
<textarea class="form-control" name="maintenance_mode_message"
|
||||
rows="3">{{ $settings['maintenance_mode_message'] ?? 'We are currently performing scheduled maintenance. We will be back shortly!' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-semibold mb-2">{{ __('Illustration / Logo') }}</label>
|
||||
<input type="file" id="maintenance_mode_image" name="maintenance_mode_image"
|
||||
accept="image/png,image/jpeg,image/svg+xml">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card adminuiux-card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-4 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-clock-fill text-primary"></i>
|
||||
{{ __('Time & Access') }}
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Secret Bypass Key') }}</label>
|
||||
<input type="text" class="form-control" name="maintenance_mode_secret"
|
||||
placeholder="e.g. admin-only"
|
||||
value="{{ $settings['maintenance_mode_secret'] ?? '' }}">
|
||||
</div>
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-12 mb-2">
|
||||
<label class="form-label fw-semibold">{{ __('End Time') }}</label>
|
||||
<input type="datetime-local" class="form-control" name="maintenance_mode_end_at"
|
||||
value="{{ !empty($settings['maintenance_mode_end_at']) ? date('Y-m-d\TH:i', strtotime($settings['maintenance_mode_end_at'])) : '' }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">{{ __('Retry Interval (Seconds)') }}</label>
|
||||
<input type="number" class="form-control" name="maintenance_mode_retry"
|
||||
value="{{ $settings['maintenance_mode_retry'] ?? 3600 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card adminuiux-card mb-4 border-warning border-opacity-25 bg-warning bg-opacity-5">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3 d-flex align-items-center gap-2 text-warning-emphasis">
|
||||
<i class="bi bi-megaphone-fill"></i>
|
||||
{{ __('Broadcast Warning') }}
|
||||
</h6>
|
||||
<p class="extra-small text-muted mb-4">
|
||||
{{ __('Alert all active users before shutting down the system. They will receive a real-time notification.') }}
|
||||
</p>
|
||||
|
||||
<div class="input-group">
|
||||
<select class="form-select" id="broadcast_minutes">
|
||||
<option value="1">1 {{ __('Minute') }}</option>
|
||||
<option value="5" selected>5 {{ __('Minutes') }}</option>
|
||||
<option value="10">10 {{ __('Minutes') }}</option>
|
||||
<option value="30">30 {{ __('Minutes') }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-warning fw-bold px-3" id="btn-broadcast-warning">
|
||||
<i class="bi bi-send-fill me-1"></i> {{ __('Send Alert') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@can('manage maintenance mode')
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary rounded-pill px-4 shadow-sm">
|
||||
{{ __('Apply Configuration') }}
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
{{-- Right: Live Preview (Col-8) --}}
|
||||
<div class="col-lg-8 animate__animated animate__fadeIn">
|
||||
<div class="mockup-container">
|
||||
<div class="browser-mockup-frame">
|
||||
{{-- Toolbar --}}
|
||||
<div class="browser-top-bar">
|
||||
<div class="browser-dots">
|
||||
<div class="browser-dot" style="background: #ff5f56;"></div>
|
||||
<div class="browser-dot" style="background: #ffbd2e;"></div>
|
||||
<div class="browser-dot" style="background: #27c93f;"></div>
|
||||
</div>
|
||||
<div class="browser-url-field">
|
||||
{{ url('/') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Viewport (Design from Image) --}}
|
||||
<div class="viewport-preview">
|
||||
<div class="visitor-card-premium">
|
||||
{{-- Maintenance Pill --}}
|
||||
<div class="under-maintenance-pill">
|
||||
<span class="pulse-dot-red"></span>
|
||||
{{ __('UNDER MAINTENANCE') }}
|
||||
</div>
|
||||
|
||||
{{-- Logo Container --}}
|
||||
<div class="mb-4 mx-auto" id="preview-mnt-image-container"
|
||||
style="max-width: 140px; min-height: 80px; display: flex; align-items: center; justify-content: center;">
|
||||
@php
|
||||
$mnt_img = $settings['maintenance_mode_image'] ?? '';
|
||||
$display_img = null;
|
||||
if (!empty($mnt_img)) {
|
||||
$display_img = str_starts_with($mnt_img, 'assets/') ? asset($mnt_img) : asset('storage/' . $mnt_img);
|
||||
} elseif (!empty($settings['app_logo'])) {
|
||||
$display_img = str_starts_with($settings['app_logo'], 'assets/') ? asset($settings['app_logo']) : asset('storage/' . $settings['app_logo']);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if($display_img)
|
||||
<img src="{{ $display_img }}" class="img-fluid" id="mnt-preview-img" alt="Logo">
|
||||
@else
|
||||
<i class="bi bi-gear-wide-connected fs-1 text-secondary opacity-25"
|
||||
style="font-size: 3.5rem !important;"></i>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Content --}}
|
||||
<h2 class="fw-black mb-3 text-dark" id="preview-mnt-title"
|
||||
style="font-family: 'Outfit', sans-serif;">
|
||||
{{ $settings['maintenance_mode_title'] ?? 'biiproject.com' }}
|
||||
</h2>
|
||||
<p class="text-secondary small mb-5" id="preview-mnt-message"
|
||||
style="line-height: 1.6; opacity: 0.8; font-weight: 500;">
|
||||
{{ $settings['maintenance_mode_message'] ?? 'We are currently performing scheduled maintenance. We will be back shortly!' }}
|
||||
</p>
|
||||
|
||||
{{-- Countdown Grid (Image Style) --}}
|
||||
<div class="countdown-grid" id="preview-mnt-countdown">
|
||||
@foreach(['Days', 'Hours', 'Mins', 'Secs'] as $label)
|
||||
<div class="countdown-square shadow-lg">
|
||||
<div class="countdown-number" id="cd-{{ strtolower($label) }}">00</div>
|
||||
<div class="countdown-label">{{ $label }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/filepond-plugin-image-preview/dist/filepond-plugin-image-preview.js" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/filepond/dist/filepond.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
// Initialize FilePond
|
||||
FilePond.registerPlugin(FilePondPluginImagePreview);
|
||||
const pond = FilePond.create(document.querySelector('#maintenance_mode_image'), {
|
||||
name: 'maintenance_mode_image',
|
||||
labelIdle: '<span class="text-muted small">Drop Logo or <span class="text-primary fw-bold">Browse</span></span>',
|
||||
imagePreviewHeight: 120,
|
||||
stylePanelLayout: 'compact',
|
||||
});
|
||||
|
||||
// Form Handling
|
||||
$('#maintenanceConfigForm').on('ajaxForm:beforeSend', function (e, formData) {
|
||||
const files = pond.getFiles();
|
||||
if (files.length > 0) {
|
||||
formData.set('maintenance_mode_image', files[0].file);
|
||||
}
|
||||
});
|
||||
|
||||
$('#maintenanceConfigForm').on('ajaxForm:success', function (e, response) {
|
||||
StandardSwal.fire({ title: 'Success!', text: response.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
|
||||
if (response.settings && response.settings.maintenance_mode_image) {
|
||||
const path = response.settings.maintenance_mode_image;
|
||||
const newUrl = path.startsWith('assets/') ? `/${path}` : `/storage/${path}`;
|
||||
originalImgSrc = `${newUrl}?v=${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
if (response.hasOwnProperty('is_down')) {
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
}
|
||||
pond.removeFiles();
|
||||
});
|
||||
|
||||
// Live Preview Sync
|
||||
$('input[name="maintenance_mode_title"]').on('input', function () {
|
||||
$('#preview-mnt-title').text($(this).val() || 'biiproject.com');
|
||||
});
|
||||
$('textarea[name="maintenance_mode_message"]').on('input', function () {
|
||||
$('#preview-mnt-message').text($(this).val() || 'Maintenance in progress...');
|
||||
});
|
||||
|
||||
// Countdown Logic
|
||||
let countdownInterval;
|
||||
function updateCountdown() {
|
||||
const val = $('input[name="maintenance_mode_end_at"]').val();
|
||||
if (!val) {
|
||||
$('#preview-mnt-countdown').addClass('opacity-25');
|
||||
return;
|
||||
}
|
||||
$('#preview-mnt-countdown').removeClass('opacity-25');
|
||||
|
||||
if (countdownInterval) clearInterval(countdownInterval);
|
||||
const target = new Date(val).getTime();
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
const now = new Date().getTime();
|
||||
const diff = target - now;
|
||||
|
||||
if (diff < 0) {
|
||||
clearInterval(countdownInterval);
|
||||
$('#cd-days, #cd-hours, #cd-mins, #cd-secs').text('00');
|
||||
return;
|
||||
}
|
||||
|
||||
const d = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const h = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const m = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const s = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
$('#cd-days').text(d.toString().padStart(2, '0'));
|
||||
$('#cd-hours').text(h.toString().padStart(2, '0'));
|
||||
$('#cd-mins').text(m.toString().padStart(2, '0'));
|
||||
$('#cd-secs').text(s.toString().padStart(2, '0'));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
$('input[name="maintenance_mode_end_at"]').on('change', updateCountdown);
|
||||
updateCountdown();
|
||||
|
||||
// Image Swap
|
||||
let originalImgSrc = $('#mnt-preview-img').attr('src');
|
||||
pond.on('addfile', (error, file) => {
|
||||
if (!error) {
|
||||
const url = URL.createObjectURL(file.file);
|
||||
$('#preview-mnt-image-container').html(`<img src="${url}" class="img-fluid" id="mnt-preview-img" alt="Logo">`);
|
||||
}
|
||||
});
|
||||
pond.on('removefile', () => {
|
||||
if (originalImgSrc) {
|
||||
$('#preview-mnt-image-container').html(`<img src="${originalImgSrc}" class="img-fluid" id="mnt-preview-img" alt="Logo">`);
|
||||
} else {
|
||||
$('#preview-mnt-image-container').html(`<i class="bi bi-gear-wide-connected fs-1 text-secondary opacity-25" style="font-size: 3.5rem !important;"></i>`);
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast Warning
|
||||
$('#btn-broadcast-warning').on('click', function () {
|
||||
const minutes = $('#broadcast_minutes').val();
|
||||
const $btn = $(this);
|
||||
|
||||
StandardSwal.fire({
|
||||
title: "{{ __('Send Broadcast Alert?') }}",
|
||||
text: "{{ __('All active users will receive a warning about the upcoming maintenance.') }}",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "{{ __('Send Alert') }}"
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1"></span>');
|
||||
|
||||
$.post("{{ route('maintenance-mode.broadcast') }}", {
|
||||
_token: "{{ csrf_token() }}",
|
||||
minutes: minutes
|
||||
}, function (response) {
|
||||
StandardSwal.fire({ title: "{{ __('Success') }}", text: response.message, icon: 'success' });
|
||||
}).always(() => $btn.prop('disabled', false).html('<i class="bi bi-send-fill me-1"></i> {{ __("Send Alert") }}'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.x-small {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,707 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.ck-editor__editable {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid pb-4">
|
||||
<div class="row align-items-center mb-4">
|
||||
<div class="col">
|
||||
<h4 class="fw-bold mb-1">{{ __('Notification Center') }}</h4>
|
||||
<p class="text-secondary small mb-0">{{ __('Manage system notifications and activity feed.') }}</p>
|
||||
</div>
|
||||
@hasanyrole('Developer|Administrator')
|
||||
<div class="col-auto d-flex flex-wrap gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-dark btn-sm rounded-pill px-3 d-inline-flex align-items-center gap-1"
|
||||
data-bs-toggle="modal" data-bs-target="#notifDocsModal">
|
||||
<i class="bi bi-book"></i>{{ __('Documentation') }}
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3 ms-2" id="page-clear-read">
|
||||
<i class="bi bi-trash3 me-1"></i>{{ __('Clear read') }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-dark btn-sm rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#sendNotificationModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>{{ __('Send') }}
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<button type="button"
|
||||
class="btn btn-outline-dark btn-sm rounded-pill px-3 d-inline-flex align-items-center gap-1"
|
||||
data-bs-toggle="modal" data-bs-target="#notifDocsModal">
|
||||
<i class="bi bi-book"></i>{{ __('Documentation') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
</div>
|
||||
@endhasanyrole
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10 col-lg-11">
|
||||
{{-- Standardized Notification Feed --}}
|
||||
<div id="notification-feed">
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
|
||||
<p class="text-secondary small mt-2">{{ __('Loading...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div id="feed-pagination" class="d-flex justify-content-center mt-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ====================================================================
|
||||
DOCUMENTATION (MODAL)
|
||||
==================================================================== --}}
|
||||
<div class="modal fade" id="notifDocsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-fullscreen-lg-down modal-dialog-scrollable modal-dialog-centered">
|
||||
<div class="modal-content rounded-4 border-0 shadow-lg overflow-hidden">
|
||||
<div class="modal-header p-0 border-0" style="background:#0f172a;">
|
||||
<div class="d-flex w-100 align-items-center justify-content-between p-4 p-lg-5 text-white">
|
||||
<div>
|
||||
<div class="extra-small opacity-50 fw-bold mb-2" style="letter-spacing:1.5px;">REFERENCE GUIDE</div>
|
||||
<h3 class="fw-black mb-2" style="letter-spacing:-1px;">{{ __('Notification Center Guide') }}</h3>
|
||||
<p class="mb-0 opacity-75" style="font-size:.9rem;line-height:1.7;max-width:720px;">
|
||||
{{ __('How notifications flow through this system — from sending to bell icon to acknowledged. Who sees what, when, and why.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-bell-fill display-3 opacity-25 d-none d-md-inline"></i>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="p-4 p-lg-5">
|
||||
|
||||
{{-- LIFECYCLE DIAGRAM --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-arrow-right-circle text-primary me-1"></i>{{ __('Notification lifecycle') }}
|
||||
</h6>
|
||||
<div class="p-4 rounded-4 mb-5" style="background:#f8fafc;border:1px solid #e2e8f0;">
|
||||
<div class="row text-center align-items-center g-3">
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-send-fill text-primary fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Sent') }}</div>
|
||||
<div class="extra-small text-muted">Admin clicks Send / system triggers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-database-fill text-info fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Persisted') }}</div>
|
||||
<div class="extra-small text-muted">Row written to <code>notifications</code> table</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-broadcast text-warning fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Broadcast') }}</div>
|
||||
<div class="extra-small text-muted">Real-time via Reverb WebSocket</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-none d-md-block"><i class="bi bi-arrow-right fs-4 text-muted"></i></div>
|
||||
<div class="col-md-2">
|
||||
<div class="p-3 rounded-4 bg-white shadow-sm h-100">
|
||||
<i class="bi bi-bell-fill text-success fs-3"></i>
|
||||
<div class="fw-bold small mt-2">{{ __('Delivered') }}</div>
|
||||
<div class="extra-small text-muted">Bell icon + feed updates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="small text-muted text-center mb-0 mt-4" style="line-height:1.7;">
|
||||
{{ __('Notifications persist in the database even after they are read — only "Clear read" deletes them. The feed paginates 15 per page.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- THREE TYPES --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-tags-fill text-info me-1"></i>{{ __('Notification types · pick the right one') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-4">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-info-circle-fill text-primary fs-3"></i>
|
||||
<div class="fw-bold text-primary fs-6">{{ __('INFORMATION') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted mb-3" style="line-height:1.7;">
|
||||
{{ __('Neutral updates. No action required from the recipient.') }}
|
||||
</div>
|
||||
<div class="extra-small text-muted">
|
||||
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
|
||||
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
|
||||
<li>{{ __('New feature announcement') }}</li>
|
||||
<li>{{ __('Scheduled maintenance reminder') }}</li>
|
||||
<li>{{ __('Newsletter / company update') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#fffbeb;border:1px solid #fde68a;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning fs-3"></i>
|
||||
<div class="fw-bold fs-6" style="color:#a16207;">{{ __('WARNING') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted mb-3" style="line-height:1.7;">
|
||||
{{ __('Recipient should pay attention. Soft urgency.') }}
|
||||
</div>
|
||||
<div class="extra-small text-muted">
|
||||
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
|
||||
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
|
||||
<li>{{ __('Quota nearing limit') }}</li>
|
||||
<li>{{ __('Pending approval required') }}</li>
|
||||
<li>{{ __('Deprecated feature retiring soon') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#fef2f2;border:1px solid #fecaca;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-megaphone-fill text-danger fs-3"></i>
|
||||
<div class="fw-bold text-danger fs-6">{{ __('SYSTEM ALERT') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted mb-3" style="line-height:1.7;">
|
||||
{{ __('Immediate attention. Affects the platform itself.') }}
|
||||
</div>
|
||||
<div class="extra-small text-muted">
|
||||
<span class="fw-bold text-dark">{{ __('Use for:') }}</span>
|
||||
<ul class="mb-0 ps-3 mt-1" style="line-height:1.8;">
|
||||
<li>{{ __('Emergency maintenance starting NOW') }}</li>
|
||||
<li>{{ __('Security incident notification') }}</li>
|
||||
<li>{{ __('Service outage / degradation') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- RECIPIENT TARGETING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-people-fill text-success me-1"></i>{{ __('Recipient targeting') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-6">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-globe text-success fs-4"></i>
|
||||
<div class="fw-bold text-success">{{ __('All Users (Public)') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted" style="line-height:1.7;">
|
||||
{{ __('Broadcast to every active account in the system, regardless of role. Use for company-wide announcements only — over-using this trains users to ignore the bell icon.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-4 rounded-4 h-100" style="background:#eff6ff;border:1px solid #bfdbfe;">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-funnel-fill text-primary fs-4"></i>
|
||||
<div class="fw-bold text-primary">{{ __('By Role') }}</div>
|
||||
</div>
|
||||
<div class="small text-muted" style="line-height:1.7;">
|
||||
{{ __('Pick a specific role (e.g. Developer, Manager, Staff). Only users holding that role will see the notification. Precise — recommended default.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- USER ACTIONS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-hand-index-fill text-warning me-1"></i>{{ __('Reader actions') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
@php
|
||||
$actions = [
|
||||
['icon'=>'bi-check2','color'=>'#22c55e','title'=>'Mark as read','desc'=>'Click a single notification card. Removes its unread highlight; stays in the feed.','boldWord'=>null],
|
||||
['icon'=>'bi-check-all','color'=>'#3b82f6','title'=>'Mark all read','desc'=>'Bulk action. Marks every unread notification at once. Useful after vacation / morning catch-up.','boldWord'=>null],
|
||||
['icon'=>'bi-trash3-fill','color'=>'#ef4444','title'=>'Clear read','desc1'=>'Permanently deletes ','boldWord'=>'already-read','desc2'=>' notifications. Unread ones are preserved. Cannot be undone.'],
|
||||
['icon'=>'bi-x-lg','color'=>'#94a3b8','title'=>'Delete one','desc'=>'Dismiss a single notification (read or unread). Removes it from your feed only — other recipients still see theirs.','boldWord'=>null],
|
||||
];
|
||||
@endphp
|
||||
@foreach($actions as $a)
|
||||
<div class="col-md-6 col-lg-3">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fff;border:1px solid #e2e8f0;">
|
||||
<div class="rounded-3 d-inline-flex align-items-center justify-content-center mb-2"
|
||||
style="background:{{ $a['color'] }}1a;color:{{ $a['color'] }};width:40px;height:40px;">
|
||||
<i class="{{ $a['icon'] }} fs-5"></i>
|
||||
</div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.88rem;">{{ $a['title'] }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">
|
||||
@if(isset($a['boldWord']))
|
||||
{{ $a['desc1'] }}<span class="fw-bold">{{ $a['boldWord'] }}</span>{{ $a['desc2'] }}
|
||||
@else
|
||||
{{ $a['desc'] }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- WRITING GUIDE --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-pencil-square text-info me-1"></i>{{ __('Writing a good notification') }}
|
||||
</h6>
|
||||
<div class="row g-3 mb-5">
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<div class="fw-bold text-success mb-2"><i class="bi bi-check-circle me-1"></i>{{ __('DO') }}</div>
|
||||
<ul class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>{{ __('Lead with the most important info — recipients scan, not read') }}</li>
|
||||
<li>{{ __('Keep title under 60 characters (it gets truncated on mobile)') }}</li>
|
||||
<li>{{ __('Use plain language, avoid internal jargon') }}</li>
|
||||
<li>{{ __('Include an action verb if user needs to do something ("Click here", "Approve", "Review")') }}</li>
|
||||
<li>{{ __('Match the type to the urgency') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 rounded-4 h-100" style="background:#fef2f2;border:1px solid #fecaca;">
|
||||
<div class="fw-bold text-danger mb-2"><i class="bi bi-x-circle me-1"></i>{{ __("DON'T") }}</div>
|
||||
<ul class="small text-muted mb-0" style="line-height:1.8;padding-left:1.2rem;">
|
||||
<li>{{ __('Use SYSTEM ALERT for routine info — it loses meaning') }}</li>
|
||||
<li>{{ __('Send the same message twice — there is no undo') }}</li>
|
||||
<li>{{ __('Include sensitive data (passwords, tokens) — notifications are stored plaintext') }}</li>
|
||||
<li>{{ __('Mass-send "All Users" for things only a few care about') }}</li>
|
||||
<li>{{ __('Forget to test on the recipient role first (small group) before broadcasting') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ARCHITECTURE --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-gear-fill text-secondary me-1"></i>{{ __('How it works under the hood') }}
|
||||
</h6>
|
||||
<div class="p-4 rounded-4 mb-5" style="background:#0f172a;color:#e2e8f0;">
|
||||
<pre class="mb-0" style="font-family:'Fira Code',monospace;font-size:.78rem;line-height:2;white-space:pre-wrap;color:#cbd5e1;">
|
||||
<span style="color:#22c55e;">DELIVERY PIPELINE:</span>
|
||||
|
||||
<span style="color:#fbbf24;">[1]</span> Admin submits form → <span style="color:#22c55e;">NotificationCenterController::store()</span>
|
||||
<span style="color:#fbbf24;">[2]</span> Recipients resolved:
|
||||
<span style="color:#94a3b8;">• "all" → User::where('active', true)->get()
|
||||
• role-X → User::role('X')->get()</span>
|
||||
<span style="color:#fbbf24;">[3]</span> Laravel <span style="color:#22c55e;">SystemManagementNotification</span> dispatched per user
|
||||
<span style="color:#94a3b8;">Channels: ['database', 'broadcast']</span>
|
||||
<span style="color:#fbbf24;">[4]</span> <span style="color:#22c55e;">database</span> channel → row in <code style="color:#f59e0b;">notifications</code> table
|
||||
<span style="color:#fbbf24;">[5]</span> <span style="color:#22c55e;">broadcast</span> channel → Reverb WebSocket → bell icon updates live
|
||||
<span style="color:#fbbf24;">[6]</span> Recipient pulls feed → <span style="color:#22c55e;">GET /notification-center/api/recent</span>
|
||||
|
||||
<span style="color:#94a3b8;"># Feature flag: <code style="color:#f59e0b;">feature_notification_center</code> in Global Settings
|
||||
# When OFF, the menu hides for non-admins but admins still see it.</span></pre>
|
||||
</div>
|
||||
|
||||
{{-- PERMISSIONS --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-person-badge text-secondary me-1"></i>{{ __('Who can do what') }}
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">{{ __('CAPABILITY') }}</th>
|
||||
<th>{{ __('REQUIRED ROLE / PERMISSION') }}</th>
|
||||
<th>{{ __('NOTES') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Read own feed') }}</td>
|
||||
<td class="small"><code>view notification center</code></td>
|
||||
<td class="small text-muted">{{ __('Required to even open this page') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Mark / delete own notifications') }}</td>
|
||||
<td class="small"><code>view notification center</code></td>
|
||||
<td class="small text-muted">{{ __('Implicit — users can always manage their own feed') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Send to a role') }}</td>
|
||||
<td class="small">{{ __('Role:') }} <span class="badge bg-dark text-white">Developer</span> {{ __('or') }} <span class="badge bg-dark text-white">Administrator</span></td>
|
||||
<td class="small text-muted">{{ __('Anyone outside these roles will not see the Send button') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Toggle the whole feature off') }}</td>
|
||||
<td class="small"><code>manage global settings</code></td>
|
||||
<td class="small text-muted">{{ __('Global Settings → Notifications →') }} <code>feature_notification_center</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- TROUBLESHOOTING --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-wrench-adjustable-circle text-secondary me-1"></i>{{ __('Troubleshooting') }}
|
||||
</h6>
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle mb-0" style="background:#f8fafc;border-radius:12px;overflow:hidden;">
|
||||
<thead style="background:#e2e8f0;">
|
||||
<tr>
|
||||
<th class="ps-3">{{ __('SYMPTOM') }}</th>
|
||||
<th>{{ __('LIKELY CAUSE') }}</th>
|
||||
<th>{{ __('FIX') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Sent but recipient sees nothing') }}</td>
|
||||
<td class="small text-muted">{{ __('Recipient has no matching role, or is inactive') }}</td>
|
||||
<td class="small text-muted">{{ __('Verify the user is active + holds the targeted role') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Bell icon does not update live') }}</td>
|
||||
<td class="small text-muted">{{ __('Reverb WebSocket disconnected') }}</td>
|
||||
<td class="small text-muted">{{ __('Check Monitoring Center → Reverb status. Restart if IDLE during traffic.') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('"Clear read" did nothing') }}</td>
|
||||
<td class="small text-muted">{{ __('Nothing was read yet — only read items get purged') }}</td>
|
||||
<td class="small text-muted">{{ __('Mark as read first, then Clear read') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Menu hidden in sidebar') }}</td>
|
||||
<td class="small text-muted">{{ __('Feature flag') }} <code>feature_notification_center</code> {{ __('is OFF') }}</td>
|
||||
<td class="small text-muted">{{ __('Global Settings → Notifications → enable the toggle') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Send button missing') }}</td>
|
||||
<td class="small text-muted">{{ __('Account lacks Developer / Administrator role') }}</td>
|
||||
<td class="small text-muted">{{ __('Ask an admin to grant the role, or use CLI to seed') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold text-dark">{{ __('Feed empty even after sending') }}</td>
|
||||
<td class="small text-muted">{{ __('The notifications table was truncated') }}</td>
|
||||
<td class="small text-muted">{{ __('Send a fresh one — old data is gone, new ones will appear') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- BEST PRACTICES --}}
|
||||
<h6 class="fw-bold text-uppercase mb-3" style="letter-spacing:.5px;">
|
||||
<i class="bi bi-stars text-success me-1"></i>{{ __('Best practices') }}
|
||||
</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
@php
|
||||
$tips = [
|
||||
['icon'=>'bi-target','title'=>'Be specific with audience','desc'=>'Target a role, not "All Users", whenever the message only matters to a subset. Saves everyone scroll time.'],
|
||||
['icon'=>'bi-clock-history','title'=>'Mind the timing','desc'=>'Avoid sending non-urgent notifications outside business hours — push fatigue eats engagement.'],
|
||||
['icon'=>'bi-funnel','title'=>'One topic per notification','desc'=>'If you have three things to say, send three notifications. Long mixed messages get skimmed.'],
|
||||
['icon'=>'bi-chat-square-quote','title'=>'Test on yourself first','desc'=>'Send to "Developer" role first, check how it looks on mobile + desktop, then broadcast.'],
|
||||
['icon'=>'bi-shield-check','title'=>'Never include secrets','desc'=>'Database rows persist until cleared. Passwords, tokens, payment info → never via notification.'],
|
||||
['icon'=>'bi-bell-slash','title'=>'Respect the bell','desc'=>'Each unnecessary notification is one step closer to users disabling the bell entirely. Less is more.'],
|
||||
];
|
||||
@endphp
|
||||
@foreach($tips as $tip)
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-start gap-2 p-3 rounded-3" style="background:#f0fdf4;border:1px solid #bbf7d0;">
|
||||
<i class="{{ $tip['icon'] }} text-success fs-4"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ $tip['title'] }}</div>
|
||||
<div class="small text-muted" style="line-height:1.6;">{{ $tip['desc'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- FOOTER NOTE --}}
|
||||
<div class="d-flex align-items-center gap-3 p-3 rounded-4" style="background:#fef3c7;border:1px solid #fde68a;">
|
||||
<i class="bi bi-info-circle-fill text-warning fs-3"></i>
|
||||
<div>
|
||||
<div class="fw-bold text-dark mb-1" style="font-size:.85rem;">{{ __('Implementation note') }}</div>
|
||||
<div class="small text-muted">
|
||||
Notifications use Laravel's built-in <code>Illuminate\Notifications</code> with two channels:
|
||||
<code>database</code> (persistent storage) and <code>broadcast</code> (real-time via Reverb WebSocket).
|
||||
The notification class is <code>App\Notifications\SystemManagementNotification</code>.
|
||||
Per-user rows live in the <code>notifications</code> table with Laravel-standard UUIDs.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL --}}
|
||||
<div class="modal fade" id="sendNotificationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3 border-0 shadow-lg">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Send Notification') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('notification-center.store') }}" id="manualNotificationForm" class="ajax-form" data-reset="true">
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Title') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control" placeholder="{{ __('Notification Title') }}" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Message') }} <span class="text-danger">*</span></label>
|
||||
<textarea id="notificationMessage" name="message" class="form-control" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Recipient') }} <span class="text-danger">*</span></label>
|
||||
<select name="recipient" class="form-select">
|
||||
<option value="all">{{ __('All Users (Public)') }}</option>
|
||||
@foreach($roles as $roleName)
|
||||
<option value="{{ $roleName }}" {{ $roleName === 'Developer' ? 'selected' : '' }}>{{ ucfirst($roleName) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Type') }} <span class="text-danger">*</span></label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="info">{{ __('Information') }}</option>
|
||||
<option value="warning">{{ __('Warning') }}</option>
|
||||
<option value="system">{{ __('System Alert') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">
|
||||
{{ __('Close') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill px-4">
|
||||
<i class="bi bi-send me-1"></i> {{ __('Send Notification') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let notificationEditor;
|
||||
let currentPage = 1;
|
||||
|
||||
const editorEl = document.querySelector('#notificationMessage');
|
||||
if (editorEl && typeof ClassicEditor !== 'undefined') {
|
||||
ClassicEditor
|
||||
.create(editorEl, {
|
||||
ckfinder: { uploadUrl: "{{ route('editor.upload') }}?_token={{ csrf_token() }}" }
|
||||
})
|
||||
.then(newEditor => {
|
||||
notificationEditor = newEditor;
|
||||
editorEl.ckeditorInstance = newEditor;
|
||||
})
|
||||
.catch(error => console.error('CKEditor Error:', error));
|
||||
}
|
||||
|
||||
// Load feed — this MUST always run
|
||||
loadFeed(1);
|
||||
|
||||
function loadFeed(page = 1) {
|
||||
currentPage = page;
|
||||
const $container = $('#notification-feed');
|
||||
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.index') }}",
|
||||
data: {
|
||||
start: (page - 1) * 10,
|
||||
length: 10,
|
||||
draw: 1
|
||||
},
|
||||
success: function(response) {
|
||||
console.log('Notification Response:', response);
|
||||
if (!response.data || response.data.length === 0) {
|
||||
$container.html(`
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-inbox h1 display-1"></i>
|
||||
<p class="small mt-2">{{ __('Inbox is empty') }}</p>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
let html = '';
|
||||
response.data.forEach(n => {
|
||||
try {
|
||||
html += window.renderNotificationCard(n);
|
||||
} catch (e) {
|
||||
console.error('Render Error:', e, n);
|
||||
}
|
||||
});
|
||||
$container.html(html);
|
||||
}
|
||||
renderPagination(response.recordsTotal, page);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('AJAX Error:', status, error, xhr.responseText);
|
||||
let message = 'Failed to load notifications.';
|
||||
try {
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
$container.html(`
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5 text-danger">
|
||||
<i class="bi bi-exclamation-circle h3"></i>
|
||||
<p class="small mt-2">${message}</p>
|
||||
<p class="text-muted smallest">Status: ${xhr.status}</p>
|
||||
<button class="btn btn-sm btn-outline-danger mt-2" onclick="location.reload()">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.reloadFeed = () => loadFeed(currentPage);
|
||||
|
||||
function renderPagination(total, current) {
|
||||
const pages = Math.ceil(total / 10);
|
||||
if (pages <= 1) { $('#feed-pagination').html(''); return; }
|
||||
|
||||
let html = '<ul class="pagination pagination-sm m-0">';
|
||||
|
||||
// Previous button
|
||||
html += `<li class="page-item ${current === 1 ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current - 1}">«</a></li>`;
|
||||
|
||||
// Page numbers with sliding window (current page +/- 2)
|
||||
const delta = 2;
|
||||
const left = current - delta;
|
||||
const right = current + delta;
|
||||
const range = [];
|
||||
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
if (i === 1 || i === pages || (i >= left && i <= right)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
for (let i of range) {
|
||||
if (last) {
|
||||
if (i - last === 2) {
|
||||
html += `<li class="page-item"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${last + 1}">${last + 1}</a></li>`;
|
||||
} else if (i - last !== 1) {
|
||||
html += `<li class="page-item disabled"><span class="page-link rounded-circle mx-1 border-0 shadow-sm">...</span></li>`;
|
||||
}
|
||||
}
|
||||
html += `<li class="page-item ${i === current ? 'active' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${i}">${i}</a></li>`;
|
||||
last = i;
|
||||
}
|
||||
|
||||
// Next button
|
||||
html += `<li class="page-item ${current === pages ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current + 1}">»</a></li>`;
|
||||
|
||||
html += '</ul>';
|
||||
$('#feed-pagination').html(html);
|
||||
}
|
||||
|
||||
$(document).on('click', '.page-link', function(e) {
|
||||
e.preventDefault();
|
||||
loadFeed($(this).data('page'));
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-delete', function() {
|
||||
const url = $(this).data('url');
|
||||
StandardSwal.fire({
|
||||
title: 'Delete this notification?',
|
||||
text: 'This notification will be permanently removed from your history.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: 'Yes, Delete',
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Notification deleted');
|
||||
},
|
||||
error: (xhr) => window.showNotificationToast('error', 'Delete failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-mark-all-read').on('click', function() {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.read-all') }}",
|
||||
method: 'PATCH',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'All marked as read');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Process failed')
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-clear-read').on('click', function() {
|
||||
StandardSwal.fire({
|
||||
title: "Clear all read notifications?",
|
||||
text: "All read updates will be permanently purged from your feed.",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Clear",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.clear-read') }}",
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Read notifications cleared');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Clear failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for standard AJAX success to reload feed
|
||||
$('#manualNotificationForm').on('ajaxForm:success', function() {
|
||||
window.reloadNotificationUI();
|
||||
if (notificationEditor) notificationEditor.setData('');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
<x-app-layout>
|
||||
@push('styles')
|
||||
<style>
|
||||
.ck-editor__editable {
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row align-items-center mb-4">
|
||||
<div class="col">
|
||||
<h4 class="fw-bold mb-1">{{ __('Notification Center') }}</h4>
|
||||
<p class="text-secondary small mb-0">{{ __('Manage system notifications and activity feed.') }}</p>
|
||||
</div>
|
||||
@hasanyrole('Developer|Administrator')
|
||||
<div class="col-auto">
|
||||
<div class="btn-group me-2">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm rounded-pill px-3 ms-2" id="page-clear-read">
|
||||
<i class="bi bi-trash3 me-1"></i>{{ __('Clear read') }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-dark btn-sm rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#sendNotificationModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>{{ __('Send') }}
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-outline-dark btn-sm rounded-pill px-3" id="page-mark-all-read">
|
||||
<i class="bi bi-check-all me-1"></i>{{ __('Mark all read') }}
|
||||
</button>
|
||||
</div>
|
||||
@endhasanyrole
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xl-10 col-lg-11">
|
||||
{{-- Standardized Notification Feed --}}
|
||||
<div id="notification-feed">
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
|
||||
<p class="text-secondary small mt-2">{{ __('Loading...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div id="feed-pagination" class="d-flex justify-content-center mt-5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MODAL --}}
|
||||
<div class="modal fade" id="sendNotificationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content rounded-3 border-0 shadow-lg">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ __('Send Notification') }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ rout('notification-center.store') }}" id="manualNotificationForm" class="ajax-form" data-reset="true">
|
||||
@csrf
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Title') }} <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control" placeholder="{{ __('Notification Title') }}" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Message') }} <span class="text-danger">*</span></label>
|
||||
<textarea id="notificationMessage" name="message" class="form-control" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Recipient') }} <span class="text-danger">*</span></label>
|
||||
<select name="recipient" class="form-select">
|
||||
<option value="all">{{ __('All Users (Public)') }}</option>
|
||||
@foreach($roles as $roleName)
|
||||
<option value="{{ $roleName }}" {{ $roleName === 'Developer' ? 'selected' : '' }}>{{ ucfirst($roleName) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-semibold">{{ __('Type') }} <span class="text-danger">*</span></label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="info">{{ __('Information') }}</option>
|
||||
<option value="warning">{{ __('Warning') }}</option>
|
||||
<option value="system">{{ __('System Alert') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">
|
||||
{{ __('Close') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-dark rounded-pill px-4">
|
||||
<i class="bi bi-send me-1"></i> {{ __('Send Notification') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.ckeditor.com/ckeditor5/41.1.0/classic/ckeditor.js" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
let notificationEditor;
|
||||
let currentPage = 1;
|
||||
|
||||
const editorEl = document.querySelector('#notificationMessage');
|
||||
if (editorEl && typeof ClassicEditor !== 'undefined') {
|
||||
ClassicEditor
|
||||
.create(editorEl, {
|
||||
ckfinder: { uploadUrl: "{{ route('editor.upload') }}?_token={{ csrf_token() }}" }
|
||||
})
|
||||
.then(newEditor => {
|
||||
notificationEditor = newEditor;
|
||||
editorEl.ckeditorInstance = newEditor;
|
||||
})
|
||||
.catch(error => console.error('CKEditor Error:', error));
|
||||
}
|
||||
|
||||
// Load feed — this MUST always run
|
||||
loadFeed(1);
|
||||
|
||||
function loadFeed(page = 1) {
|
||||
currentPage = page;
|
||||
const $container = $('#notification-feed');
|
||||
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.index') }}",
|
||||
data: {
|
||||
start: (page - 1) * 10,
|
||||
length: 10,
|
||||
draw: 1
|
||||
},
|
||||
success: function(response) {
|
||||
console.log('Notification Response:', response);
|
||||
if (!response.data || response.data.length === 0) {
|
||||
$container.html(`
|
||||
<div class="text-center py-5 opacity-50">
|
||||
<i class="bi bi-inbox h1 display-1"></i>
|
||||
<p class="small mt-2">{{ __('Inbox is empty') }}</p>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
let html = '';
|
||||
response.data.forEach(n => {
|
||||
try {
|
||||
html += window.renderNotificationCard(n);
|
||||
} catch (e) {
|
||||
console.error('Render Error:', e, n);
|
||||
}
|
||||
});
|
||||
$container.html(html);
|
||||
}
|
||||
renderPagination(response.recordsTotal, page);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('AJAX Error:', status, error, xhr.responseText);
|
||||
let message = 'Failed to load notifications.';
|
||||
try {
|
||||
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||
message = xhr.responseJSON.message;
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
$container.html(`
|
||||
<div class="card adminuiux-card border-0 shadow-sm rounded-3">
|
||||
<div class="card-body text-center py-5 text-danger">
|
||||
<i class="bi bi-exclamation-circle h3"></i>
|
||||
<p class="small mt-2">${message}</p>
|
||||
<p class="text-muted smallest">Status: ${xhr.status}</p>
|
||||
<button class="btn btn-sm btn-outline-danger mt-2" onclick="location.reload()">Refresh Page</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.reloadFeed = () => loadFeed(currentPage);
|
||||
|
||||
function renderPagination(total, current) {
|
||||
const pages = Math.ceil(total / 10);
|
||||
if (pages <= 1) { $('#feed-pagination').html(''); return; }
|
||||
|
||||
let html = '<ul class="pagination pagination-sm m-0">';
|
||||
|
||||
// Previous button
|
||||
html += `<li class="page-item ${current === 1 ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current - 1}">«</a></li>`;
|
||||
|
||||
// Page numbers with sliding window (current page +/- 2)
|
||||
const delta = 2;
|
||||
const left = current - delta;
|
||||
const right = current + delta;
|
||||
const range = [];
|
||||
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
if (i === 1 || i === pages || (i >= left && i <= right)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
for (let i of range) {
|
||||
if (last) {
|
||||
if (i - last === 2) {
|
||||
html += `<li class="page-item"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${last + 1}">${last + 1}</a></li>`;
|
||||
} else if (i - last !== 1) {
|
||||
html += `<li class="page-item disabled"><span class="page-link rounded-circle mx-1 border-0 shadow-sm">...</span></li>`;
|
||||
}
|
||||
}
|
||||
html += `<li class="page-item ${i === current ? 'active' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${i}">${i}</a></li>`;
|
||||
last = i;
|
||||
}
|
||||
|
||||
// Next button
|
||||
html += `<li class="page-item ${current === pages ? 'disabled' : ''}"><a class="page-link rounded-circle mx-1 border-0 shadow-sm" href="#" data-page="${current + 1}">»</a></li>`;
|
||||
|
||||
html += '</ul>';
|
||||
$('#feed-pagination').html(html);
|
||||
}
|
||||
|
||||
$(document).on('click', '.page-link', function(e) {
|
||||
e.preventDefault();
|
||||
loadFeed($(this).data('page'));
|
||||
});
|
||||
|
||||
$(document).on('click', '.btn-delete', function() {
|
||||
const url = $(this).data('url');
|
||||
StandardSwal.fire({
|
||||
title: 'Delete this notification?',
|
||||
text: 'This notification will be permanently removed from your history.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: 'Yes, Delete',
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Notification deleted');
|
||||
},
|
||||
error: (xhr) => window.showNotificationToast('error', 'Delete failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-mark-all-read').on('click', function() {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.read-all') }}",
|
||||
method: 'PATCH',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'All marked as read');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Process failed')
|
||||
});
|
||||
});
|
||||
|
||||
$('#page-clear-read').on('click', function() {
|
||||
StandardSwal.fire({
|
||||
title: "Clear all read notifications?",
|
||||
text: "All read updates will be permanently purged from your feed.",
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
confirmButtonText: "Yes, Clear",
|
||||
cancelButtonText: "Cancel",
|
||||
}).then(result => {
|
||||
if (result.isConfirmed) {
|
||||
$.ajax({
|
||||
url: "{{ route('notification-center.clear-read') }}",
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') },
|
||||
success: (res) => {
|
||||
window.reloadNotificationUI();
|
||||
window.showNotificationToast('success', res.message || 'Read notifications cleared');
|
||||
},
|
||||
error: () => window.showNotificationToast('error', 'Clear failed')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for standard AJAX success to reload feed
|
||||
$('#manualNotificationForm').on('ajaxForm:success', function() {
|
||||
window.reloadNotificationUI();
|
||||
if (notificationEditor) notificationEditor.setData('');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,246 @@
|
||||
<x-app-layout>
|
||||
<div id="session-manager-root" x-data="{
|
||||
sessionDetail: { session: { user_name: '', user_email: '', ip_address: '', id: '' }, device: { browser: '', browser_icon: '', os: '', os_icon: '' }, time: '', is_current: false },
|
||||
openSession(btn) {
|
||||
try {
|
||||
this.sessionDetail = JSON.parse(btn.dataset.activity);
|
||||
const modalEl = document.getElementById('sessionDetailModal');
|
||||
let modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (!modal) {
|
||||
modal = new bootstrap.Modal(modalEl);
|
||||
}
|
||||
modal.show();
|
||||
} catch (e) {
|
||||
console.error('Error opening session detail:', e);
|
||||
window.StandardSwal.fire({ title: 'Error', text: 'Could not parse session data.', icon: 'error' });
|
||||
}
|
||||
},
|
||||
async terminate() {
|
||||
const sid = this.sessionDetail.session.id;
|
||||
const url = `{{ route('session-manager.terminate', ':id') }}`.replace(':id', sid);
|
||||
|
||||
const r = await window.StandardSwal.fire({
|
||||
title: 'Terminate Connection?',
|
||||
text: 'Sever all encrypted links for this digital session?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, Terminate'
|
||||
});
|
||||
|
||||
if (r.isConfirmed) {
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('sessionDetailModal')).hide();
|
||||
window.reloadDataTable?.();
|
||||
window.StandardSwal.fire({ title: 'Severed', text: data.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
copyMetadata() {
|
||||
navigator.clipboard.writeText(JSON.stringify(this.sessionDetail.session, null, 2));
|
||||
window.StandardSwal.fire({ title: 'Copied!', text: 'Session metadata context copied.', icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
}">
|
||||
<div class="container-fluid" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
<div class="col-12">
|
||||
<div class="card adminuiux-card">
|
||||
<div class="card-body p-0">
|
||||
|
||||
{{-- Header Section --}}
|
||||
<div class="p-4 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0 fw-bold">{{ __('Session Manager') }}</h5>
|
||||
<small class="text-muted">
|
||||
{{ __('Real-time monitoring and management of authenticated user sessions.') }}
|
||||
</small>
|
||||
</div>
|
||||
<button @click="window.reloadDataTable?.()" class="btn btn-outline-secondary btn-sm rounded-pill px-3">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i> {{ __('Refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Table Section --}}
|
||||
<div class="p-4">
|
||||
<div class="table-responsive">
|
||||
<table id="datatables" class="table table-hover table-bordered w-100 nowrap mb-0"
|
||||
data-server-side="true" data-ajax-url="{{ route('session-manager') }}"
|
||||
data-order='@json([[4, "desc"]])'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-wrap">{{ __('Status') }}</th>
|
||||
<th class="text-wrap">{{ __('Identity Profile') }}</th>
|
||||
<th class="text-wrap">{{ __('Device') }}</th>
|
||||
<th class="text-wrap">{{ __('IP Address') }}</th>
|
||||
<th class="text-wrap">{{ __('Last Activity') }}</th>
|
||||
<th class="text-wrap text-end" data-orderable="false" data-searchable="false">{{ __('Action') }}</th>
|
||||
</tr>
|
||||
|
||||
{{-- Filter Row --}}
|
||||
<tr class="filter-row">
|
||||
<th>
|
||||
<select class="form-select form-select-sm">
|
||||
<option value="">{{ __('All') }}</option>
|
||||
<option value="active">{{ __('Active') }}</option>
|
||||
<option value="ended">{{ __('Idle') }}</option>
|
||||
</select>
|
||||
</th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Identity') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Device') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('IP Trace') }}"></th>
|
||||
<th><input class="form-control form-control-sm" placeholder="{{ __('Search Time') }}"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Session Diagnostic Modal (Bootstrap) --}}
|
||||
<div class="modal fade" id="sessionDetailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content rounded-4 border-0 shadow-lg">
|
||||
<div class="modal-header border-bottom-0 p-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div>
|
||||
<h5 class="modal-title fw-bold mb-0">{{ __('Session Diagnostics') }}</h5>
|
||||
<small class="text-uppercase text-muted fw-black ls-1" style="font-size: 10px;">{{ __('End-to-End Security Audit') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body p-4 pt-0">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold mb-2" style="font-size: 10px;">{{ __('Identity Context') }}</label>
|
||||
<div class="p-3 bg-light rounded-3 border">
|
||||
<div class="fw-bold text-dark" x-text="sessionDetail.session.user_name || 'Guest Observer'"></div>
|
||||
<div class="small text-muted" x-text="sessionDetail.session.user_email || 'anonymous'"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold mb-2 d-block text-md-end" style="font-size: 10px;">{{ __('Temporal Sync') }}</label>
|
||||
<div class="p-3 bg-light rounded-3 border text-md-end">
|
||||
<div class="fw-bold text-dark" x-text="sessionDetail.time"></div>
|
||||
<div class="small text-muted text-uppercase ls-1" style="font-size: 10px;">Last Pulse Detected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold mb-2" style="font-size: 10px;">{{ __('Connected Node Details') }}</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card card-body text-center py-4 rounded-4 border">
|
||||
<i class="bi fs-2 mb-2" :class="sessionDetail.device.browser_icon"></i>
|
||||
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.device.browser"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card card-body text-center py-4 rounded-4 border">
|
||||
<i class="bi fs-2 mb-2" :class="sessionDetail.device.os_icon"></i>
|
||||
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.device.os"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card card-body text-center py-4 rounded-4 border">
|
||||
<i class="bi bi-broadcast fs-2 mb-2"></i>
|
||||
<div class="fw-black text-uppercase ls-1" style="font-size: 10px;" x-text="sessionDetail.session.ip_address"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="ls-1 text-uppercase text-muted fw-bold" style="font-size: 10px;">{{ __('Raw Metadata Payload') }}</label>
|
||||
<a href="javascript:void(0)" @click="copyMetadata" class="text-decoration-none fw-bold ls-1 text-uppercase" style="font-size: 10px;">Copy manifest</a>
|
||||
</div>
|
||||
<div class="bg-dark rounded-4 p-4 shadow-inner">
|
||||
<pre class="text-success m-0 fw-mono small overflow-auto no-scrollbar" style="max-height: 200px;" x-text="JSON.stringify(sessionDetail.session, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer border-top-0 p-4">
|
||||
<button type="button" class="btn btn-outline-dark rounded-pill px-4" data-bs-dismiss="modal">Close</button>
|
||||
@can('manage active sessions')
|
||||
<template x-if="!sessionDetail.is_current">
|
||||
<button type="button" @click="terminate" class="btn btn-danger rounded-pill px-4 shadow-sm">Terminate Session</button>
|
||||
</template>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const tableElement = $('#datatables');
|
||||
|
||||
window.reloadDataTable = () => tableElement.DataTable().ajax.reload(null, false);
|
||||
|
||||
$(document).on("click", ".btn-detail-session", function() {
|
||||
const root = document.getElementById('session-manager-root');
|
||||
if (root && window.Alpine) {
|
||||
const alpine = window.Alpine.$data(root);
|
||||
alpine.openSession(this);
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", ".btn-terminate-session", async function() {
|
||||
const id = $(this).data('id');
|
||||
const url = $(this).data('url');
|
||||
const r = await window.StandardSwal.fire({
|
||||
title: 'Terminate Connection?',
|
||||
text: 'Sever all encrypted links for this digital session?',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, Terminate'
|
||||
});
|
||||
if (r.isConfirmed) {
|
||||
const res = await fetch(url, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' } });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
window.reloadDataTable();
|
||||
window.StandardSwal.fire({ title: 'Severed', text: data.message, icon: 'success', timer: 1500, showConfirmButton: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tableElement.on('draw.dt', function() {
|
||||
tableElement.find('tbody tr').each(function() {
|
||||
const rowData = tableElement.DataTable().row(this).data();
|
||||
if (rowData && rowData[6] === true) {
|
||||
$(this).addClass('table-success-light');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.table-success-light { background-color: rgba(25, 135, 84, 0.05) !important; border-left: 4px solid #198754 !important; }
|
||||
.ls-1 { letter-spacing: 0.1em; }
|
||||
.fw-black { font-weight: 900; }
|
||||
.extra-small { font-size: 10px; }
|
||||
pre::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
pre::-webkit-scrollbar-thumb { background: rgba(25, 135, 84, 0.3); border-radius: 4px; }
|
||||
</style>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
<x-app-layout>
|
||||
<div class="container-fluid mt-2" id="main-content">
|
||||
<div class="row gx-3 gx-lg-4">
|
||||
|
||||
<div class="col-12 col-lg-4">
|
||||
|
||||
{{-- Foto + Summary --}}
|
||||
<div class="card adminuiux-card border-0 overflow-hidden mb-3">
|
||||
<div class="text-center position-relative p-3">
|
||||
|
||||
{{-- Foto avatar utama --}}
|
||||
<div class="avatar avatar-120 rounded-circle border border-3 border-white shadow mt-n5">
|
||||
<img src="assets/img/profile.png" class="rounded-circle">
|
||||
</div>
|
||||
|
||||
{{-- Nama & role user --}}
|
||||
<h5 class="mt-3 mb-0 fw-bold">{{ Auth::user()->name ?? 'Mobileuxer' }}</h5>
|
||||
<p class="text-secondary small">Admin</p>
|
||||
|
||||
{{-- Informasi user --}}
|
||||
<div class="text-start mt-3 small">
|
||||
<p><i class="bi bi-envelope me-2"></i> {{ Auth::user()->email }}</p>
|
||||
<p><i class="bi bi-telephone me-2"></i> {{ Auth::user()->phone ?? 'N/A' }}</p>
|
||||
<p><i class="bi bi-cake me-2"></i> {{ Auth::user()->date_of_birth ?? 'N/A' }}</p>
|
||||
<p><i class="bi bi-geo-alt me-2"></i> {{ Auth::user()->city ?? 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Delete Account --}}
|
||||
@include('profile.partials.delete-user-form')
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8">
|
||||
|
||||
{{-- Profile Information --}}
|
||||
@include('profile.partials.update-profile-information-form')
|
||||
|
||||
{{-- Update Password --}}
|
||||
@include('profile.partials.update-password-form')
|
||||
|
||||
{{-- Passkey Registration (WebAuthn) --}}
|
||||
@if(get_setting('webauthn_enabled', false))
|
||||
@include('profile.partials.passkey-registration-form')
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,71 @@
|
||||
<div class="card adminuiux-card border-danger shadow-sm mt-4">
|
||||
<div class="card-body">
|
||||
|
||||
{{-- header and description --}}
|
||||
<h4 class="fw-bold text-danger mb-2">Delete Account</h4>
|
||||
<p class="text-secondary small mb-3">
|
||||
Once deleted, all your data will be permanently removed. Download any information you want to keep.
|
||||
</p>
|
||||
|
||||
{{-- trigger button for sweetalert confirmation --}}
|
||||
<button id="deleteAccountBtn" class="btn btn-danger mt-3 px-3">
|
||||
Delete Account
|
||||
</button>
|
||||
|
||||
{{-- delete account form (submitted after sweetalert confirmation) --}}
|
||||
<form id="deleteAccountForm" method="post" action="{{ route('profile.destroy') }}" style="display:none;">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<input type="hidden" name="password" id="passwordInput" required>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- sweetalert2 cdn --}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
document.getElementById('deleteAccountBtn').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// show confirmation dialog
|
||||
StandardSwal.fire({
|
||||
title: 'Delete Your Account?',
|
||||
text: "This action is irreversible. All your personal data and resources will be permanently wiped. Please enter your password to authorize this deletion.",
|
||||
icon: 'warning',
|
||||
input: 'password',
|
||||
inputPlaceholder: 'Confirm your password',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Permanently Delete',
|
||||
customClass: {
|
||||
confirmButton: 'btn-pill-danger',
|
||||
cancelButton: 'btn-pill-cancel'
|
||||
},
|
||||
reverseButtons: false,
|
||||
preConfirm: (password) => {
|
||||
if (!password)
|
||||
StandardSwal.showValidationMessage('Authentication password is required');
|
||||
return password;
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
// if confirmed, submit form
|
||||
if (result.isConfirmed) {
|
||||
document.getElementById('passwordInput').value = result.value;
|
||||
document.getElementById('deleteAccountForm').submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{{-- sweetalert error if password is incorrect (server validation) --}}
|
||||
@if ($errors->userDeletion->has('password'))
|
||||
<script>
|
||||
StandardSwal.fire({
|
||||
icon: 'error',
|
||||
title: 'Verification Failed!',
|
||||
text: '{{ $errors->userDeletion->first("password") }}' || 'The password you provided is incorrect.',
|
||||
confirmButtonText: 'Try Again'
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
@@ -0,0 +1,121 @@
|
||||
<section>
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<header>
|
||||
<h4 class="fw-bold mb-3">
|
||||
<i class="bi bi-fingerprint me-2"></i> {{ __('Passkeys (WebAuthn)') }}
|
||||
</h6>
|
||||
<p class="text-secondary small mb-4">
|
||||
{{ __('Register your biometrics (Fingerprint, FaceID) or security keys to login securely without a password.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div id="passkey-list" class="mb-4">
|
||||
@if(Auth::user()->webAuthnCredentials->count() > 0)
|
||||
<div class="list-group list-group-flush border-top border-bottom mb-3">
|
||||
@foreach(Auth::user()->webAuthnCredentials as $credential)
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center py-2 px-0">
|
||||
<div>
|
||||
<span class="fw-semibold small d-block">{{ $credential->alias ?: __('Unnamed Device') }}</span>
|
||||
<small class="text-muted">{{ __('Registered on') }}: {{ $credential->created_at->format('d M Y') }}</small>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('webauthn.destroy', $credential->id) }}" onsubmit="return confirm('{{ __('Are you sure you want to remove this passkey?') }}')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger border-0">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="alert alert-light border small text-center py-3">
|
||||
{{ __('No passkeys registered yet.') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button type="button" id="register-passkey" class="btn btn-primary theme-black px-4">
|
||||
{{ __('Register New Passkey') }}
|
||||
</button>
|
||||
<div id="passkey-status" class="small text-info d-none">
|
||||
<span class="spinner-border spinner-border-sm me-1"></span> {{ __('Processing...') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(get_setting('webauthn_enabled', false))
|
||||
<script src="{{ asset('vendor/webauthn/webauthn.js') }}"></script>
|
||||
<script>
|
||||
document.getElementById('register-passkey').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('register-passkey');
|
||||
const status = document.getElementById('passkey-status');
|
||||
|
||||
btn.disabled = true;
|
||||
status.classList.remove('d-none');
|
||||
|
||||
if (typeof WebAuthn === 'undefined') {
|
||||
console.error('WebAuthn library not loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
const webauthn = new WebAuthn();
|
||||
|
||||
if (WebAuthn.supportsWebAuthn()) {
|
||||
try {
|
||||
// Ask for a nickname for the key
|
||||
const alias = prompt("{{ __('Enter a name for this device/key (e.g. My Laptop, iPhone):') }}", "My Device");
|
||||
|
||||
if (alias === null) {
|
||||
btn.disabled = false;
|
||||
status.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Laragear WebAuthn v5 registration
|
||||
const response = await webauthn.register({ alias: alias });
|
||||
|
||||
if (response) {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn Error:', error);
|
||||
|
||||
let errorMsg = 'Failed to register Passkey. Please use a secure connection.';
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
errorMsg = 'Passkeys require a secure context (HTTPS). \n\n' +
|
||||
'Developer Tip: If you are on a local domain (like .test), you can bypass this in Chrome/Edge by going to: \n' +
|
||||
'chrome://flags/#unsafely-treat-insecure-origin-as-secure \n' +
|
||||
'and adding your domain to the list.';
|
||||
} else if (error.name === 'NotAllowedError') {
|
||||
errorMsg = 'Registration was cancelled or timed out.';
|
||||
} else {
|
||||
errorMsg = 'Failed to register Passkey. Ensure your device supports biometrics and is configured correctly.';
|
||||
}
|
||||
|
||||
StandardSwal.fire({
|
||||
icon: 'error',
|
||||
title: 'Registration Failed!',
|
||||
text: errorMsg || 'The system was unable to register your Passkey. Please ensure your device is compatible.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
StandardSwal.fire({
|
||||
icon: 'info',
|
||||
title: 'Passkey Not Supported',
|
||||
text: 'Your current browser or device does not support secure Passkey authentication.',
|
||||
confirmButtonText: 'Understood'
|
||||
});
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
status.classList.add('d-none');
|
||||
});
|
||||
</script>
|
||||
@endif
|
||||
</section>
|
||||
@@ -0,0 +1,75 @@
|
||||
<div class="card adminuiux-card mb-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
|
||||
{{-- header --}}
|
||||
<h4 class="fw-bold mb-3">Update Password</h4>
|
||||
<p class="text-secondary small mb-4">Make sure your new password is secure.</p>
|
||||
{{-- form update password --}}
|
||||
<form method="POST" action="{{ route('password.update') }}" autocomplete="off" class="ajax-form">
|
||||
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
{{-- Current Password --}}
|
||||
<div class="mb-3">
|
||||
<label for="update_password_current_password" class="form-label fw-semibold">
|
||||
{{ __('Current Password') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" id="update_password_current_password" name="current_password"
|
||||
class="form-control border-end-0" placeholder="Enter current password" required autocomplete="off" minlength="12"
|
||||
title="Enter your current password">
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- New Password --}}
|
||||
<div class="mb-3">
|
||||
<label for="update_password_password" class="form-label fw-semibold">
|
||||
{{ __('New Password') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" id="update_password_password" name="password" class="form-control border-end-0"
|
||||
placeholder="Enter new password" required minlength="12" autocomplete="new-password"
|
||||
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{12,}$"
|
||||
title="Minimum 12 characters with uppercase, lowercase, number, and symbol">
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Confirm Password --}}
|
||||
<div class="mb-3">
|
||||
<label for="update_password_password_confirmation" class="form-label fw-semibold">
|
||||
{{ __('Confirm Password') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" id="update_password_password_confirmation" name="password_confirmation"
|
||||
class="form-control border-end-0" placeholder="Confirm new password" required minlength="12"
|
||||
autocomplete="new-password" title="Must match the new password exactly">
|
||||
<button class="btn btn-outline-secondary bg-white border-start-0 password-toggle" type="button" style="border-color: #dee2e6;">
|
||||
<i class="bi bi-eye text-secondary"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- submit --}}
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button type="submit" class="btn btn-primary mt-3 px-3">
|
||||
{{ __('Update Password') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- redundant script removed --}}
|
||||
@@ -0,0 +1,57 @@
|
||||
<div class="card adminuiux-card mb-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
|
||||
{{-- page information header --}}
|
||||
<h4 class="fw-bold mb-3">Profile Information</h4>
|
||||
<p class="text-secondary small mb-4">
|
||||
update your account's profile information and email address
|
||||
</p>
|
||||
|
||||
{{-- form for resending verification email (hidden, triggered in backend) --}}
|
||||
<form id="send-verification" method="post" action="{{ route('verification.send') }}" style="display:none;">
|
||||
@csrf
|
||||
</form>
|
||||
{{-- update profile form --}}
|
||||
<form method="POST" action="{{ route('profile.update') }}" autocomplete="off" class="ajax-form">
|
||||
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
|
||||
{{-- anti autofill trap --}}
|
||||
<input type="text" name="fakeuser" style="display:none">
|
||||
<input type="password" name="fakepass" style="display:none">
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
{{-- Name --}}
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Name') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ old('name', auth()->user()->name) }}"
|
||||
placeholder="ex: John Wick" required minlength="3" maxlength="100" pattern="^[a-zA-Z\s]+$"
|
||||
title="Name must be at least 3 characters and contain letters and spaces only">
|
||||
</div>
|
||||
|
||||
{{-- Email --}}
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label fw-semibold">
|
||||
{{ __('Email') }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="email" name="email" class="form-control"
|
||||
value="{{ old('email', auth()->user()->email) }}" placeholder="john@email.com" required
|
||||
maxlength="150" title="Enter a valid and unique email address">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-3 px-3">
|
||||
{{ __('Save Changes') }}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- redundant script removed --}}
|
||||
@@ -0,0 +1,67 @@
|
||||
@php
|
||||
use Filament\Actions\View\ActionsRenderHook;
|
||||
use Filament\Support\Facades\FilamentView;
|
||||
|
||||
$actionModalAlignment = $action->getModalAlignment();
|
||||
$actionIsModalAutofocused = $action->isModalAutofocused();
|
||||
$actionHasModalCloseButton = $action->hasModalCloseButton();
|
||||
$actionIsModalClosedByClickingAway = $action->isModalClosedByClickingAway();
|
||||
$actionIsModalClosedByEscaping = $action->isModalClosedByEscaping();
|
||||
$actionModalDescription = $action->getModalDescription();
|
||||
$actionExtraModalWindowAttributeBag = $action->getExtraModalWindowAttributeBag();
|
||||
$actionModalFooterActions = $action->getVisibleModalFooterActions();
|
||||
$actionModalFooterActionsAlignment = $action->getModalFooterActionsAlignment();
|
||||
$actionModalHeading = $action->getModalHeading();
|
||||
$actionModalIcon = $action->getModalIcon();
|
||||
$actionModalIconColor = $action->getModalIconColor();
|
||||
$actionModalId = "fi-{$this->getId()}-action-{$action->getNestingIndex()}";
|
||||
$actionIsModalSlideOver = $action->isModalSlideOver();
|
||||
$actionIsModalFooterSticky = $action->isModalFooterSticky();
|
||||
$actionIsModalHeaderSticky = $action->isModalHeaderSticky();
|
||||
$actionModalWidth = $action->getModalWidth();
|
||||
$actionLivewireCallMountedActionName = $action->hasFormWrapper() ? $action->getLivewireCallMountedActionName() : null;
|
||||
$actionModalWireKey = "{$this->getId()}.actions.{$action->getName()}.modal";
|
||||
@endphp
|
||||
|
||||
<x-filament::modal
|
||||
:alignment="$actionModalAlignment"
|
||||
:autofocus="$actionIsModalAutofocused"
|
||||
:close-button="$actionHasModalCloseButton"
|
||||
:close-by-clicking-away="$actionIsModalClosedByClickingAway"
|
||||
:close-by-escaping="$actionIsModalClosedByEscaping"
|
||||
:description="$actionModalDescription"
|
||||
:extra-modal-window-attribute-bag="$actionExtraModalWindowAttributeBag"
|
||||
:footer-actions="$actionModalFooterActions"
|
||||
:footer-actions-alignment="$actionModalFooterActionsAlignment"
|
||||
:heading="$actionModalHeading"
|
||||
:icon="$actionModalIcon"
|
||||
:icon-color="$actionModalIconColor"
|
||||
:id="$actionModalId"
|
||||
:slide-over="$actionIsModalSlideOver"
|
||||
:sticky-footer="$actionIsModalFooterSticky"
|
||||
:sticky-header="$actionIsModalHeaderSticky"
|
||||
:width="$actionModalWidth"
|
||||
:wire:key="$actionModalWireKey"
|
||||
:wire:submit.prevent="$actionLivewireCallMountedActionName"
|
||||
:x-on:modal-closed="'if ($event.detail.id === ' . \Illuminate\Support\Js::from($actionModalId) . ') $wire.unmountAction(false)'"
|
||||
>
|
||||
{{ FilamentView::renderHook(ActionsRenderHook::MODAL_CUSTOM_CONTENT_BEFORE, scopes: static::class, data: ['action' => $action]) }}
|
||||
|
||||
{{ $action->getModalContent() }}
|
||||
|
||||
{{ FilamentView::renderHook(ActionsRenderHook::MODAL_CUSTOM_CONTENT_AFTER, scopes: static::class, data: ['action' => $action]) }}
|
||||
|
||||
@if ($this->mountedActionHasSchema(mountedAction: $action))
|
||||
{{ FilamentView::renderHook(ActionsRenderHook::MODAL_SCHEMA_BEFORE, scopes: static::class, data: ['action' => $action]) }}
|
||||
|
||||
{{ $this->getMountedActionSchema(mountedAction: $action) }}
|
||||
|
||||
{{ FilamentView::renderHook(ActionsRenderHook::MODAL_SCHEMA_AFTER, scopes: static::class, data: ['action' => $action]) }}
|
||||
@endif
|
||||
|
||||
{{ FilamentView::renderHook(ActionsRenderHook::MODAL_CUSTOM_CONTENT_FOOTER_BEFORE, scopes: static::class, data: ['action' => $action]) }}
|
||||
|
||||
{{ $action->getModalContentFooter() }}
|
||||
|
||||
{{ FilamentView::renderHook(ActionsRenderHook::MODAL_CUSTOM_CONTENT_FOOTER_AFTER, scopes: static::class, data: ['action' => $action]) }}
|
||||
</x-filament::modal>
|
||||
@@ -0,0 +1,64 @@
|
||||
@props([
|
||||
'actions' => [],
|
||||
'badge' => null,
|
||||
'badgeColor' => null,
|
||||
'button' => false,
|
||||
'buttonGroup' => null,
|
||||
'color' => null,
|
||||
'dropdownMaxHeight' => null,
|
||||
'dropdownOffset' => null,
|
||||
'dropdownPlacement' => null,
|
||||
'dropdownWidth' => null,
|
||||
'group' => null,
|
||||
'icon' => null,
|
||||
'iconSize' => null,
|
||||
'iconButton' => false,
|
||||
'label' => null,
|
||||
'link' => false,
|
||||
'size' => null,
|
||||
'tooltip' => null,
|
||||
'triggerView' => null,
|
||||
'view' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$group ??= \Filament\Actions\ActionGroup::make($actions)
|
||||
->badgeColor($badgeColor)
|
||||
->color($color)
|
||||
->dropdownMaxHeight($dropdownMaxHeight)
|
||||
->dropdownOffset($dropdownOffset)
|
||||
->dropdownPlacement($dropdownPlacement)
|
||||
->dropdownWidth($dropdownWidth)
|
||||
->icon($icon)
|
||||
->iconSize($iconSize)
|
||||
->label($label)
|
||||
->size($size)
|
||||
->tooltip($tooltip)
|
||||
->triggerView($triggerView)
|
||||
->view($view);
|
||||
|
||||
$badge === true
|
||||
? $group->badge()
|
||||
: $group->badge($badge);
|
||||
|
||||
if ($button) {
|
||||
$group
|
||||
->button()
|
||||
->iconPosition($attributes->get('iconPosition') ?? $attributes->get('icon-position'))
|
||||
->outlined($attributes->get('outlined') ?? false);
|
||||
}
|
||||
|
||||
if ($buttonGroup) {
|
||||
$group->buttonGroup();
|
||||
}
|
||||
|
||||
if ($iconButton) {
|
||||
$group->iconButton();
|
||||
}
|
||||
|
||||
if ($link) {
|
||||
$group->link();
|
||||
}
|
||||
@endphp
|
||||
|
||||
{{ $group }}
|
||||
@@ -0,0 +1,19 @@
|
||||
@if ($this instanceof \Filament\Actions\Contracts\HasActions && (! $this->hasActionsModalRendered))
|
||||
<div
|
||||
wire:partial="action-modals"
|
||||
x-data="filamentActionModals({
|
||||
livewireId: @js($this->getId()),
|
||||
})"
|
||||
style="height: 0"
|
||||
>
|
||||
@foreach ($this->getMountedActions() as $action)
|
||||
@if ((! $loop->last) || $this->mountedActionShouldOpenModal())
|
||||
{{ $action->toModalHtmlable() }}
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@php
|
||||
$this->hasActionsModalRendered = true;
|
||||
@endphp
|
||||
@endif
|
||||
@@ -0,0 +1,328 @@
|
||||
@php
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$items = $getItems();
|
||||
$blockPickerBlocks = $getBlockPickerBlocks();
|
||||
$blockPickerColumns = $getBlockPickerColumns();
|
||||
$blockPickerWidth = $getBlockPickerWidth();
|
||||
$hasBlockPreviews = $hasBlockPreviews();
|
||||
$hasInteractiveBlockPreviews = $hasInteractiveBlockPreviews();
|
||||
|
||||
$addAction = $getAction($getAddActionName());
|
||||
$addActionAlignment = $getAddActionAlignment();
|
||||
$addBetweenAction = $getAction($getAddBetweenActionName());
|
||||
$cloneAction = $getAction($getCloneActionName());
|
||||
$collapseAllAction = $getAction($getCollapseAllActionName());
|
||||
$editAction = $getAction($getEditActionName());
|
||||
$expandAllAction = $getAction($getExpandAllActionName());
|
||||
$deleteAction = $getAction($getDeleteActionName());
|
||||
$moveDownAction = $getAction($getMoveDownActionName());
|
||||
$moveUpAction = $getAction($getMoveUpActionName());
|
||||
$reorderAction = $getAction($getReorderActionName());
|
||||
$extraItemActions = $getExtraItemActions();
|
||||
|
||||
$isAddable = $isAddable();
|
||||
$isCloneable = $isCloneable();
|
||||
$isCollapsible = $isCollapsible();
|
||||
$isDeletable = $isDeletable();
|
||||
$isReorderableWithButtons = $isReorderableWithButtons();
|
||||
$isReorderableWithDragAndDrop = $isReorderableWithDragAndDrop();
|
||||
|
||||
$collapseAllActionIsVisible = $isCollapsible && $collapseAllAction->isVisible();
|
||||
$expandAllActionIsVisible = $isCollapsible && $expandAllAction->isVisible();
|
||||
$persistCollapsed = $shouldPersistCollapsed();
|
||||
|
||||
$key = $getKey();
|
||||
$statePath = $getStatePath();
|
||||
|
||||
$blockLabelHeadingTag = $getHeadingTag();
|
||||
$isBlockLabelTruncated = $isBlockLabelTruncated();
|
||||
$labelBetweenItems = $getLabelBetweenItems();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
{{
|
||||
$attributes
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->class([
|
||||
'fi-fo-builder',
|
||||
'fi-collapsible' => $isCollapsible,
|
||||
])
|
||||
}}
|
||||
>
|
||||
@if ($collapseAllActionIsVisible || $expandAllActionIsVisible)
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-builder-actions',
|
||||
'fi-hidden' => count($items) < 2,
|
||||
])
|
||||
>
|
||||
@if ($collapseAllActionIsVisible)
|
||||
<span
|
||||
x-on:click="$dispatch('builder-collapse', '{{ $statePath }}')"
|
||||
>
|
||||
{{ $collapseAllAction }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($expandAllActionIsVisible)
|
||||
<span
|
||||
x-on:click="$dispatch('builder-expand', '{{ $statePath }}')"
|
||||
>
|
||||
{{ $expandAllAction }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (count($items))
|
||||
<ul
|
||||
x-sortable
|
||||
data-sortable-animation-duration="{{ $getReorderAnimationDuration() }}"
|
||||
x-on:end.stop="
|
||||
$wire.mountAction(
|
||||
'reorder',
|
||||
{ items: $event.target.sortable.toArray() },
|
||||
{ schemaComponent: '{{ $key }}' },
|
||||
)
|
||||
"
|
||||
class="fi-fo-builder-items"
|
||||
>
|
||||
@php
|
||||
$hasBlockLabels = $hasBlockLabels();
|
||||
$hasBlockIcons = $hasBlockIcons();
|
||||
$hasBlockNumbers = $hasBlockNumbers();
|
||||
$hasBlockHeaders = $hasBlockHeaders();
|
||||
@endphp
|
||||
|
||||
@foreach ($items as $itemKey => $item)
|
||||
@php
|
||||
$visibleExtraItemActions = array_filter(
|
||||
$extraItemActions,
|
||||
fn (Action $action): bool => $action(['item' => $itemKey])->isVisible(),
|
||||
);
|
||||
$cloneAction = $cloneAction(['item' => $itemKey]);
|
||||
$cloneActionIsVisible = $isCloneable && $cloneAction->isVisible();
|
||||
$deleteAction = $deleteAction(['item' => $itemKey]);
|
||||
$deleteActionIsVisible = $isDeletable && $deleteAction->isVisible();
|
||||
$editAction = $editAction(['item' => $itemKey]);
|
||||
$editActionIsVisible = $hasBlockPreviews && $editAction->isVisible();
|
||||
$moveDownAction = $moveDownAction(['item' => $itemKey])->disabled($loop->last);
|
||||
$moveDownActionIsVisible = $isReorderableWithButtons && $moveDownAction->isVisible();
|
||||
$moveUpAction = $moveUpAction(['item' => $itemKey])->disabled($loop->first);
|
||||
$moveUpActionIsVisible = $isReorderableWithButtons && $moveUpAction->isVisible();
|
||||
$reorderActionIsVisible = $isReorderableWithDragAndDrop && $reorderAction->isVisible();
|
||||
$hasItemHeader = $hasBlockHeaders && ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible || $hasBlockIcons || $hasBlockLabels || $editActionIsVisible || $cloneActionIsVisible || $deleteActionIsVisible || $isCollapsible || $visibleExtraItemActions);
|
||||
@endphp
|
||||
|
||||
<li
|
||||
wire:ignore.self
|
||||
wire:key="{{ $item->getLivewireKey() }}.item"
|
||||
x-data="{
|
||||
isCollapsed: @if ($persistCollapsed) $persist(@js($isCollapsed($item))).as(`builder-${@js($key)}-${@js($itemKey)}-isCollapsed`) @else @js($isCollapsed($item)) @endif,
|
||||
}"
|
||||
x-on:builder-expand.window="$event.detail === '{{ $statePath }}' && (isCollapsed = false)"
|
||||
x-on:builder-collapse.window="$event.detail === '{{ $statePath }}' && (isCollapsed = true)"
|
||||
x-on:expand="isCollapsed = false"
|
||||
x-sortable-item="{{ $itemKey }}"
|
||||
{{
|
||||
$item->getParentComponent()->getExtraAttributeBag()
|
||||
->class([
|
||||
'fi-fo-builder-item',
|
||||
'fi-fo-builder-item-has-header' => $hasItemHeader,
|
||||
])
|
||||
}}
|
||||
x-bind:class="{ 'fi-collapsed': isCollapsed }"
|
||||
>
|
||||
@if ($hasItemHeader)
|
||||
<div
|
||||
@if ($isCollapsible)
|
||||
x-on:click.stop="isCollapsed = !isCollapsed"
|
||||
@endif
|
||||
class="fi-fo-builder-item-header"
|
||||
>
|
||||
@if ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible)
|
||||
<ul
|
||||
class="fi-fo-builder-item-header-start-actions"
|
||||
>
|
||||
@if ($reorderActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $reorderAction->extraAttributes(['x-sortable-handle' => true], merge: true) }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($moveUpActionIsVisible || $moveDownActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $moveUpAction }}
|
||||
</li>
|
||||
|
||||
<li x-on:click.stop>
|
||||
{{ $moveDownAction }}
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$blockIcon = $item->getParentComponent()->getIcon($item->getRawState(), $itemKey);
|
||||
@endphp
|
||||
|
||||
@if ($hasBlockIcons && filled($blockIcon))
|
||||
{{ \Filament\Support\generate_icon_html($blockIcon, attributes: (new \Illuminate\View\ComponentAttributeBag)->class(['fi-fo-builder-item-header-icon'])) }}
|
||||
@endif
|
||||
|
||||
@if ($hasBlockLabels)
|
||||
<{{ $blockLabelHeadingTag }}
|
||||
@class([
|
||||
'fi-fo-builder-item-header-label',
|
||||
'fi-truncated' => $isBlockLabelTruncated,
|
||||
])
|
||||
>
|
||||
{{ $item->getParentComponent()->getLabel($item->getRawState(), $itemKey) }}
|
||||
|
||||
@if ($hasBlockNumbers)
|
||||
{{ $loop->iteration }}
|
||||
@endif
|
||||
</{{ $blockLabelHeadingTag }}>
|
||||
@endif
|
||||
|
||||
@if ($editActionIsVisible || $cloneActionIsVisible || $deleteActionIsVisible || $isCollapsible || $visibleExtraItemActions)
|
||||
<ul
|
||||
class="fi-fo-builder-item-header-end-actions"
|
||||
>
|
||||
@foreach ($visibleExtraItemActions as $extraItemAction)
|
||||
<li x-on:click.stop>
|
||||
{{ $extraItemAction(['item' => $itemKey]) }}
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@if ($editActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $editAction }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($cloneActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $cloneAction }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($deleteActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $deleteAction }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($isCollapsible)
|
||||
<li
|
||||
class="fi-fo-builder-item-header-collapsible-actions"
|
||||
x-on:click.stop="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<div
|
||||
class="fi-fo-builder-item-header-collapse-action"
|
||||
>
|
||||
{{ $getAction('collapse') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="fi-fo-builder-item-header-expand-action"
|
||||
>
|
||||
{{ $getAction('expand') }}
|
||||
</div>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
x-show="! isCollapsed"
|
||||
@class([
|
||||
'fi-fo-builder-item-content',
|
||||
'fi-fo-builder-item-content-has-preview' => $hasBlockPreviews && $item->getParentComponent()->hasPreview(),
|
||||
])
|
||||
>
|
||||
@if ($hasBlockPreviews && $item->getParentComponent()->hasPreview())
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-builder-item-preview',
|
||||
'fi-interactive' => $hasInteractiveBlockPreviews,
|
||||
])
|
||||
>
|
||||
{{ $item->getParentComponent()->renderPreview($item->getRawState()) }}
|
||||
</div>
|
||||
|
||||
@if ($editActionIsVisible && (! $hasInteractiveBlockPreviews))
|
||||
<div
|
||||
class="fi-fo-builder-item-preview-edit-overlay"
|
||||
role="button"
|
||||
x-on:click.stop="{{ '$wire.mountAction(\'edit\', { item: \'' . $itemKey . '\' }, { schemaComponent: \'' . $key . '\' })' }}"
|
||||
></div>
|
||||
@endif
|
||||
@else
|
||||
{{ $item }}
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (! $loop->last)
|
||||
@if ($isAddable && $addBetweenAction(['afterItem' => $itemKey])->isVisible())
|
||||
<li class="fi-fo-builder-add-between-items-ctn">
|
||||
<div class="fi-fo-builder-add-between-items">
|
||||
<div class="fi-fo-builder-block-picker-ctn">
|
||||
<x-filament-forms::builder.block-picker
|
||||
:action="$addBetweenAction"
|
||||
:after-item="$itemKey"
|
||||
:columns="$blockPickerColumns"
|
||||
:blocks="$blockPickerBlocks"
|
||||
:key="$key"
|
||||
:width="$blockPickerWidth"
|
||||
>
|
||||
<x-slot name="trigger">
|
||||
{{ $addBetweenAction(['afterItem' => $itemKey]) }}
|
||||
</x-slot>
|
||||
</x-filament-forms::builder.block-picker>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@elseif (filled($labelBetweenItems))
|
||||
<li class="fi-fo-builder-label-between-items-ctn">
|
||||
<div
|
||||
class="fi-fo-builder-label-between-items-divider-before"
|
||||
></div>
|
||||
|
||||
<span class="fi-fo-builder-label-between-items">
|
||||
{{ $labelBetweenItems }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="fi-fo-builder-label-between-items-divider-after"
|
||||
></div>
|
||||
</li>
|
||||
@endif
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@if ($isAddable && $addAction->isVisible())
|
||||
<x-filament-forms::builder.block-picker
|
||||
:action="$addAction"
|
||||
:action-alignment="$addActionAlignment"
|
||||
:blocks="$blockPickerBlocks"
|
||||
:columns="$blockPickerColumns"
|
||||
:key="$key"
|
||||
:width="$blockPickerWidth"
|
||||
>
|
||||
<x-slot name="trigger">
|
||||
{{ $addAction }}
|
||||
</x-slot>
|
||||
</x-filament-forms::builder.block-picker>
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
@php
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Filament\Support\Enums\GridDirection;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
@endphp
|
||||
|
||||
@props([
|
||||
'action',
|
||||
'actionAlignment' => null,
|
||||
'afterItem' => null,
|
||||
'blocks',
|
||||
'columns' => null,
|
||||
'key',
|
||||
'trigger',
|
||||
'width' => null,
|
||||
])
|
||||
|
||||
<x-filament::dropdown
|
||||
:placement="
|
||||
match ($actionAlignment) {
|
||||
Alignment::Start, Alignment::Left => 'bottom-start',
|
||||
Alignment::End, Alignment::Right => 'bottom-end',
|
||||
default => null,
|
||||
}
|
||||
"
|
||||
shift
|
||||
:width="$width"
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes(
|
||||
$attributes->class([
|
||||
'fi-fo-builder-block-picker',
|
||||
($actionAlignment instanceof Alignment) ? ('fi-align-' . $actionAlignment->value) : $actionAlignment => $actionAlignment,
|
||||
]),
|
||||
)
|
||||
"
|
||||
>
|
||||
<x-slot name="trigger">
|
||||
{{ $trigger }}
|
||||
</x-slot>
|
||||
|
||||
<x-filament::dropdown.list>
|
||||
<div
|
||||
{{ (new ComponentAttributeBag)->grid($columns, GridDirection::Column) }}
|
||||
>
|
||||
@foreach ($blocks as $block)
|
||||
@php
|
||||
$blockIcon = $block->getIcon();
|
||||
|
||||
$wireClickActionArguments = ['block' => $block->getName()];
|
||||
|
||||
if (filled($afterItem)) {
|
||||
$wireClickActionArguments['afterItem'] = $afterItem;
|
||||
}
|
||||
|
||||
$wireClickActionArguments = \Illuminate\Support\Js::from($wireClickActionArguments);
|
||||
|
||||
$wireClickAction = "mountAction('{$action->getName()}', {$wireClickActionArguments}, { schemaComponent: '{$key}' })";
|
||||
@endphp
|
||||
|
||||
<x-filament::dropdown.list.item
|
||||
:icon="$blockIcon"
|
||||
x-on:click="close"
|
||||
:wire:click="$wireClickAction"
|
||||
>
|
||||
{{ $block->getLabel() }}
|
||||
</x-filament::dropdown.list.item>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
@@ -0,0 +1,151 @@
|
||||
@php
|
||||
use Filament\Support\Enums\GridDirection;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraInputAttributeBag = $getExtraInputAttributeBag();
|
||||
$isHtmlAllowed = $isHtmlAllowed();
|
||||
$gridDirection = $getGridDirection() ?? GridDirection::Column;
|
||||
$isBulkToggleable = $isBulkToggleable();
|
||||
$isDisabled = $isDisabled();
|
||||
$isSearchable = $isSearchable();
|
||||
$statePath = $getStatePath();
|
||||
$options = $getOptions();
|
||||
$livewireKey = $getLivewireKey();
|
||||
$wireModelAttribute = $applyStateBindingModifiers('wire:model');
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('checkbox-list', 'filament/forms') }}"
|
||||
x-data="checkboxListFormComponent({
|
||||
livewireId: @js($this->getId()),
|
||||
})"
|
||||
{{ $getExtraAlpineAttributeBag()->class(['fi-fo-checkbox-list']) }}
|
||||
>
|
||||
@if (! $isDisabled)
|
||||
@if ($isSearchable)
|
||||
<x-filament::input.wrapper
|
||||
inline-prefix
|
||||
:prefix-icon="\Filament\Support\Icons\Heroicon::MagnifyingGlass"
|
||||
:prefix-icon-alias="\Filament\Forms\View\FormsIconAlias::COMPONENTS_CHECKBOX_LIST_SEARCH_FIELD"
|
||||
class="fi-fo-checkbox-list-search-input-wrp"
|
||||
>
|
||||
<input
|
||||
placeholder="{{ $getSearchPrompt() }}"
|
||||
type="search"
|
||||
x-model.debounce.{{ $getSearchDebounce() }}="search"
|
||||
class="fi-input fi-input-has-inline-prefix"
|
||||
/>
|
||||
</x-filament::input.wrapper>
|
||||
@endif
|
||||
|
||||
@if ($isBulkToggleable && count($options))
|
||||
<div
|
||||
x-cloak
|
||||
class="fi-fo-checkbox-list-actions"
|
||||
wire:key="{{ $livewireKey }}.actions"
|
||||
>
|
||||
<span
|
||||
x-show="! areAllCheckboxesChecked"
|
||||
x-on:click="toggleAllCheckboxes()"
|
||||
wire:key="{{ $livewireKey }}.actions.select-all"
|
||||
>
|
||||
{{ $getAction('selectAll') }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
x-show="areAllCheckboxesChecked"
|
||||
x-on:click="toggleAllCheckboxes()"
|
||||
wire:key="{{ $livewireKey }}.actions.deselect-all"
|
||||
>
|
||||
{{ $getAction('deselectAll') }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div
|
||||
{{
|
||||
$getExtraAttributeBag()
|
||||
->grid($getColumns(), $gridDirection)
|
||||
->merge([
|
||||
'x-show' => $isSearchable ? 'visibleCheckboxListOptions.length' : null,
|
||||
], escape: false)
|
||||
->class([
|
||||
'fi-fo-checkbox-list-options',
|
||||
])
|
||||
}}
|
||||
>
|
||||
@forelse ($options as $value => $label)
|
||||
<div
|
||||
wire:key="{{ $livewireKey }}.options.{{ $value }}"
|
||||
@if ($isSearchable)
|
||||
x-show="
|
||||
$el
|
||||
.querySelector('.fi-fo-checkbox-list-option-label')
|
||||
?.innerText.toLowerCase()
|
||||
.includes(search.toLowerCase()) ||
|
||||
$el
|
||||
.querySelector('.fi-fo-checkbox-list-option-description')
|
||||
?.innerText.toLowerCase()
|
||||
.includes(search.toLowerCase())
|
||||
"
|
||||
@endif
|
||||
class="fi-fo-checkbox-list-option-ctn"
|
||||
>
|
||||
<label class="fi-fo-checkbox-list-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
{{
|
||||
$extraInputAttributeBag
|
||||
->merge([
|
||||
'disabled' => $isDisabled || $isOptionDisabled($value, $label),
|
||||
'value' => $value,
|
||||
'wire:loading.attr' => 'disabled',
|
||||
$wireModelAttribute => $statePath,
|
||||
'x-on:change' => $isBulkToggleable ? 'checkIfAllCheckboxesAreChecked()' : null,
|
||||
], escape: false)
|
||||
->class([
|
||||
'fi-checkbox-input',
|
||||
'fi-valid' => ! $errors->has($statePath),
|
||||
'fi-invalid' => $errors->has($statePath),
|
||||
])
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="fi-fo-checkbox-list-option-text">
|
||||
<span class="fi-fo-checkbox-list-option-label">
|
||||
@if ($isHtmlAllowed)
|
||||
{!! $label !!}
|
||||
@else
|
||||
{{ $label }}
|
||||
@endif
|
||||
</span>
|
||||
|
||||
@if ($hasDescription($value))
|
||||
<p
|
||||
class="fi-fo-checkbox-list-option-description"
|
||||
>
|
||||
{{ $getDescription($value) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@empty
|
||||
<div wire:key="{{ $livewireKey }}.empty"></div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($isSearchable)
|
||||
<div
|
||||
x-cloak
|
||||
x-show="search && ! visibleCheckboxListOptions.length"
|
||||
class="fi-fo-checkbox-list-no-search-results-message"
|
||||
>
|
||||
{{ $getNoSearchResultsMessage() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,34 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$statePath = $getStatePath();
|
||||
$attributes = $attributes
|
||||
->merge([
|
||||
'autofocus' => $isAutofocused(),
|
||||
'disabled' => $isDisabled(),
|
||||
'id' => $getId(),
|
||||
'required' => $isRequired() && (! $isConcealed()),
|
||||
'wire:loading.attr' => 'disabled',
|
||||
$applyStateBindingModifiers('wire:model') => $statePath,
|
||||
], escape: false)
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->merge($getExtraInputAttributes(), escape: false)
|
||||
->class([
|
||||
'fi-checkbox-input',
|
||||
'fi-valid' => ! $errors->has($statePath),
|
||||
'fi-invalid' => $errors->has($statePath),
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
:inline-label-vertical-alignment="\Filament\Support\Enums\VerticalAlignment::Center"
|
||||
>
|
||||
@if ($isInline())
|
||||
<x-slot name="labelPrefix">
|
||||
<input type="checkbox" {{ $attributes }} />
|
||||
</x-slot>
|
||||
@else
|
||||
<input type="checkbox" {{ $attributes }} />
|
||||
@endif
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,49 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributeBag = $getExtraAttributeBag();
|
||||
$isDisabled = $isDisabled();
|
||||
$isLive = $isLive();
|
||||
$isLiveOnBlur = $isLiveOnBlur();
|
||||
$isLiveDebounced = $isLiveDebounced();
|
||||
$liveDebounce = $getLiveDebounce();
|
||||
$key = $getKey();
|
||||
$language = $getLanguage();
|
||||
$statePath = $getStatePath();
|
||||
$livewireKey = $getLivewireKey();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<x-filament::input.wrapper
|
||||
:disabled="$isDisabled"
|
||||
:valid="! $errors->has($statePath)"
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes($extraAttributeBag)
|
||||
->class(['fi-fo-code-editor'])
|
||||
"
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('code-editor', 'filament/forms') }}"
|
||||
x-data="codeEditorFormComponent({
|
||||
canWrap: @js($canWrap()),
|
||||
isDisabled: @js($isDisabled),
|
||||
isLive: @js($isLive),
|
||||
isLiveDebounced: @js($isLiveDebounced),
|
||||
isLiveOnBlur: @js($isLiveOnBlur),
|
||||
liveDebounce: @js($liveDebounce),
|
||||
language: @js($language?->value),
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')", isOptimisticallyLive: false) }},
|
||||
})"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.{{
|
||||
substr(md5(serialize([
|
||||
$isDisabled,
|
||||
$language?->value,
|
||||
])), 0, 64)
|
||||
}}"
|
||||
{{ $getExtraAlpineAttributeBag() }}
|
||||
>
|
||||
<div x-ref="editor" x-cloak></div>
|
||||
</div>
|
||||
</x-filament::input.wrapper>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,117 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributeBag = $getExtraAttributeBag();
|
||||
$isDisabled = $isDisabled();
|
||||
$isLive = $isLive();
|
||||
$isLiveOnBlur = $isLiveOnBlur();
|
||||
$isLiveDebounced = $isLiveDebounced();
|
||||
$isPrefixInline = $isPrefixInline();
|
||||
$isSuffixInline = $isSuffixInline();
|
||||
$liveDebounce = $getLiveDebounce();
|
||||
$prefixActions = $getPrefixActions();
|
||||
$prefixIcon = $getPrefixIcon();
|
||||
$prefixIconColor = $getPrefixIconColor();
|
||||
$prefixLabel = $getPrefixLabel();
|
||||
$suffixActions = $getSuffixActions();
|
||||
$suffixIcon = $getSuffixIcon();
|
||||
$suffixIconColor = $getSuffixIconColor();
|
||||
$suffixLabel = $getSuffixLabel();
|
||||
$statePath = $getStatePath();
|
||||
$placeholder = $getPlaceholder();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
:inline-label-vertical-alignment="\Filament\Support\Enums\VerticalAlignment::Center"
|
||||
>
|
||||
<x-filament::input.wrapper
|
||||
:disabled="$isDisabled"
|
||||
:inline-prefix="$isPrefixInline"
|
||||
:inline-suffix="$isSuffixInline"
|
||||
:prefix="$prefixLabel"
|
||||
:prefix-actions="$prefixActions"
|
||||
:prefix-icon="$prefixIcon"
|
||||
:prefix-icon-color="$prefixIconColor"
|
||||
:suffix="$suffixLabel"
|
||||
:suffix-actions="$suffixActions"
|
||||
:suffix-icon="$suffixIcon"
|
||||
:suffix-icon-color="$suffixIconColor"
|
||||
:valid="! $errors->has($statePath)"
|
||||
x-on:focus-input.stop="$el.querySelector('input')?.focus()"
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes($extraAttributeBag)
|
||||
->class('fi-fo-color-picker')
|
||||
"
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('color-picker', 'filament/forms') }}"
|
||||
x-data="colorPickerFormComponent({
|
||||
isAutofocused: @js($isAutofocused()),
|
||||
isDisabled: @js($isDisabled),
|
||||
isLive: @js($isLive),
|
||||
isLiveDebounced: @js($isLiveDebounced),
|
||||
isLiveOnBlur: @js($isLiveOnBlur),
|
||||
liveDebounce: @js($liveDebounce),
|
||||
state: $wire.$entangle('{{ $statePath }}'),
|
||||
})"
|
||||
x-on:keydown.esc="isOpen() && $event.stopPropagation()"
|
||||
x-on:focusout="if (isOpen() && ! $el.contains($event.relatedTarget)) $refs.panel.close()"
|
||||
{{ $getExtraAlpineAttributeBag()->class(['fi-input-wrp-content']) }}
|
||||
>
|
||||
<input
|
||||
x-on:focus="$refs.panel.open($refs.input)"
|
||||
x-on:keydown.enter.prevent.stop="togglePanelVisibility()"
|
||||
x-ref="input"
|
||||
{{
|
||||
$getExtraInputAttributeBag()
|
||||
->merge([
|
||||
'autocomplete' => 'off',
|
||||
'disabled' => $isDisabled,
|
||||
'id' => $getId(),
|
||||
'placeholder' => filled($placeholder) ? e($placeholder) : null,
|
||||
'required' => $isRequired() && (! $isConcealed()),
|
||||
'type' => 'text',
|
||||
'x-model' . ($isLiveDebounced ? '.debounce.' . $liveDebounce : null) => 'state',
|
||||
'x-on:blur' => $isLiveOnBlur ? 'isOpen() ? null : commitState()' : null,
|
||||
], escape: false)
|
||||
->class([
|
||||
'fi-input',
|
||||
'fi-input-has-inline-prefix' => $isPrefixInline && (count($prefixActions) || $prefixIcon || filled($prefixLabel)),
|
||||
'fi-input-has-inline-suffix' => $isSuffixInline && (count($suffixActions) || $suffixIcon || filled($suffixLabel)),
|
||||
])
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="fi-fo-color-picker-preview my-auto me-3 size-5 shrink-0 rounded-full select-none"
|
||||
x-on:click="togglePanelVisibility()"
|
||||
x-bind:class="{
|
||||
'fi-empty': ! state,
|
||||
}"
|
||||
x-bind:style="{ 'background-color': state }"
|
||||
></div>
|
||||
|
||||
<div
|
||||
wire:ignore.self
|
||||
wire:key="{{ $getLivewireKey() }}.panel"
|
||||
x-cloak
|
||||
x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }"
|
||||
x-ref="panel"
|
||||
class="fi-fo-color-picker-panel"
|
||||
>
|
||||
@php
|
||||
$tag = match ($getFormat()) {
|
||||
'hsl' => 'hsl-string',
|
||||
'rgb' => 'rgb-string',
|
||||
'rgba' => 'rgba-string',
|
||||
default => 'hex',
|
||||
} . '-color-picker';
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} x-ref="picker" color="{{ $getState() }}" />
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::input.wrapper>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,296 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$datalistOptions = $getDatalistOptions();
|
||||
$disabledDates = $getDisabledDates();
|
||||
$extraAlpineAttributes = $getExtraAlpineAttributes();
|
||||
$extraAttributeBag = $getExtraAttributeBag();
|
||||
$extraInputAttributeBag = $getExtraInputAttributeBag();
|
||||
$hasDate = $hasDate();
|
||||
$hasTime = $hasTime();
|
||||
$hasSeconds = $hasSeconds();
|
||||
$id = $getId();
|
||||
$isDisabled = $isDisabled();
|
||||
$isAutofocused = $isAutofocused();
|
||||
$isPrefixInline = $isPrefixInline();
|
||||
$isSuffixInline = $isSuffixInline();
|
||||
$maxDate = $getMaxDate();
|
||||
$minDate = $getMinDate();
|
||||
$defaultFocusedDate = $getDefaultFocusedDate();
|
||||
$prefixActions = $getPrefixActions();
|
||||
$prefixIcon = $getPrefixIcon();
|
||||
$prefixIconColor = $getPrefixIconColor();
|
||||
$prefixLabel = $getPrefixLabel();
|
||||
$suffixActions = $getSuffixActions();
|
||||
$suffixIcon = $getSuffixIcon();
|
||||
$suffixIconColor = $getSuffixIconColor();
|
||||
$suffixLabel = $getSuffixLabel();
|
||||
$statePath = $getStatePath();
|
||||
$placeholder = $getPlaceholder();
|
||||
$isReadOnly = $isReadOnly();
|
||||
$isRequired = $isRequired();
|
||||
$isConcealed = $isConcealed();
|
||||
$step = $getStep();
|
||||
$type = $getType();
|
||||
$livewireKey = $getLivewireKey();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
:inline-label-vertical-alignment="\Filament\Support\Enums\VerticalAlignment::Center"
|
||||
>
|
||||
<x-filament::input.wrapper
|
||||
:disabled="$isDisabled"
|
||||
:inline-prefix="$isPrefixInline"
|
||||
:inline-suffix="$isSuffixInline"
|
||||
:prefix="$prefixLabel"
|
||||
:prefix-actions="$prefixActions"
|
||||
:prefix-icon="$prefixIcon"
|
||||
:prefix-icon-color="$prefixIconColor"
|
||||
:suffix="$suffixLabel"
|
||||
:suffix-actions="$suffixActions"
|
||||
:suffix-icon="$suffixIcon"
|
||||
:suffix-icon-color="$suffixIconColor"
|
||||
:valid="! $errors->has($statePath)"
|
||||
x-on:focus-input.stop="$el.querySelector('input:not([type=hidden])')?.focus()"
|
||||
:attributes="\Filament\Support\prepare_inherited_attributes($extraAttributeBag)->class(['fi-fo-date-time-picker'])"
|
||||
>
|
||||
@if ($isNative())
|
||||
<input
|
||||
{{
|
||||
$extraInputAttributeBag
|
||||
->merge($extraAlpineAttributes, escape: false)
|
||||
->merge([
|
||||
'autofocus' => $isAutofocused,
|
||||
'disabled' => $isDisabled,
|
||||
'id' => $id,
|
||||
'list' => $datalistOptions ? $id . '-list' : null,
|
||||
'max' => $hasTime ? $maxDate : ($maxDate ? \Carbon\Carbon::parse($maxDate)->toDateString() : null),
|
||||
'min' => $hasTime ? $minDate : ($minDate ? \Carbon\Carbon::parse($minDate)->toDateString() : null),
|
||||
'placeholder' => filled($placeholder) ? e($placeholder) : null,
|
||||
'readonly' => $isReadOnly,
|
||||
'required' => $isRequired && (! $isConcealed),
|
||||
'step' => $step,
|
||||
'type' => $type,
|
||||
$applyStateBindingModifiers('wire:model') => $statePath,
|
||||
'x-data' => count($extraAlpineAttributes) ? '{}' : null,
|
||||
], escape: false)
|
||||
->class([
|
||||
'fi-input',
|
||||
'fi-input-has-inline-prefix' => $isPrefixInline && (count($prefixActions) || $prefixIcon || filled($prefixLabel)),
|
||||
'fi-input-has-inline-suffix' => $isSuffixInline && (count($suffixActions) || $suffixIcon || filled($suffixLabel)),
|
||||
])
|
||||
}}
|
||||
/>
|
||||
@else
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('date-time-picker', 'filament/forms') }}"
|
||||
x-data="dateTimePickerFormComponent({
|
||||
defaultFocusedDate: @js($defaultFocusedDate),
|
||||
displayFormat:
|
||||
'{{ convert_date_format($getDisplayFormat())->to('day.js') }}',
|
||||
firstDayOfWeek: {{ $getFirstDayOfWeek() }},
|
||||
isAutofocused: @js($isAutofocused),
|
||||
locale: @js($getLocale()),
|
||||
shouldCloseOnDateSelection: @js($shouldCloseOnDateSelection()),
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
|
||||
})"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.{{
|
||||
substr(md5(serialize([
|
||||
$disabledDates,
|
||||
$isDisabled,
|
||||
$isReadOnly,
|
||||
$maxDate,
|
||||
$minDate,
|
||||
$hasDate,
|
||||
$hasTime,
|
||||
$hasSeconds,
|
||||
])), 0, 64)
|
||||
}}"
|
||||
x-on:keydown.esc="isOpen() && $event.stopPropagation()"
|
||||
{{ $getExtraAlpineAttributeBag() }}
|
||||
>
|
||||
<input x-ref="maxDate" type="hidden" value="{{ $maxDate }}" />
|
||||
|
||||
<input x-ref="minDate" type="hidden" value="{{ $minDate }}" />
|
||||
|
||||
<input
|
||||
x-ref="disabledDates"
|
||||
type="hidden"
|
||||
value="{{ json_encode($disabledDates) }}"
|
||||
/>
|
||||
|
||||
<button
|
||||
x-ref="button"
|
||||
x-on:click="togglePanelVisibility()"
|
||||
x-on:keydown.enter.prevent.stop="
|
||||
if (! $el.disabled) {
|
||||
isOpen() ? selectDate() : togglePanelVisibility()
|
||||
}
|
||||
"
|
||||
x-on:keydown.arrow-left.prevent.stop="if (! $el.disabled) focusPreviousDay()"
|
||||
x-on:keydown.arrow-right.prevent.stop="if (! $el.disabled) focusNextDay()"
|
||||
x-on:keydown.arrow-up.prevent.stop="if (! $el.disabled) focusPreviousWeek()"
|
||||
x-on:keydown.arrow-down.prevent.stop="if (! $el.disabled) focusNextWeek()"
|
||||
x-on:keydown.backspace.prevent.stop="if (! $el.disabled) clearState()"
|
||||
x-on:keydown.clear.prevent.stop="if (! $el.disabled) clearState()"
|
||||
x-on:keydown.delete.prevent.stop="if (! $el.disabled) clearState()"
|
||||
aria-label="{{ $placeholder }}"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
@disabled($isDisabled || $isReadOnly)
|
||||
{{
|
||||
$getExtraTriggerAttributeBag()->class([
|
||||
'fi-fo-date-time-picker-trigger',
|
||||
])
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@disabled($isDisabled)
|
||||
readonly
|
||||
placeholder="{{ $placeholder }}"
|
||||
wire:key="{{ $livewireKey }}.display-text"
|
||||
x-model="displayText"
|
||||
@if ($id = $getId()) id="{{ $id }}" @endif
|
||||
@class([
|
||||
'fi-fo-date-time-picker-display-text-input',
|
||||
])
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-ref="panel"
|
||||
x-cloak
|
||||
x-float.placement.bottom-start.offset.flip.shift="{ offset: 8 }"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.panel"
|
||||
@class([
|
||||
'fi-fo-date-time-picker-panel',
|
||||
])
|
||||
>
|
||||
@if ($hasDate)
|
||||
<div class="fi-fo-date-time-picker-panel-header">
|
||||
<select
|
||||
x-model="focusedMonth"
|
||||
class="fi-fo-date-time-picker-month-select"
|
||||
>
|
||||
<template x-for="(month, index) in months">
|
||||
<option
|
||||
x-bind:value="index"
|
||||
x-text="month"
|
||||
></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
x-model.debounce="focusedYear"
|
||||
class="fi-fo-date-time-picker-year-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fi-fo-date-time-picker-calendar-header">
|
||||
<template
|
||||
x-for="(day, index) in dayLabels"
|
||||
x-bind:key="index"
|
||||
>
|
||||
<div
|
||||
x-text="day"
|
||||
class="fi-fo-date-time-picker-calendar-header-day"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="grid"
|
||||
class="fi-fo-date-time-picker-calendar"
|
||||
>
|
||||
<template
|
||||
x-for="day in emptyDaysInFocusedMonth"
|
||||
x-bind:key="day"
|
||||
>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<template
|
||||
x-for="day in daysInFocusedMonth"
|
||||
x-bind:key="day"
|
||||
>
|
||||
<div
|
||||
x-text="day"
|
||||
x-on:click="dayIsDisabled(day) || selectDate(day)"
|
||||
x-on:mouseenter="setFocusedDay(day)"
|
||||
role="option"
|
||||
x-bind:aria-selected="focusedDate.date() === day"
|
||||
x-bind:class="{
|
||||
'fi-fo-date-time-picker-calendar-day-today': dayIsToday(day),
|
||||
'fi-focused': focusedDate.date() === day,
|
||||
'fi-selected': dayIsSelected(day),
|
||||
'fi-disabled': dayIsDisabled(day),
|
||||
}"
|
||||
class="fi-fo-date-time-picker-calendar-day"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hasTime)
|
||||
<div class="fi-fo-date-time-picker-time-inputs">
|
||||
<input
|
||||
max="23"
|
||||
min="0"
|
||||
step="{{ $getHoursStep() }}"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
x-model.debounce="hour"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="fi-fo-date-time-picker-time-input-separator"
|
||||
>
|
||||
:
|
||||
</span>
|
||||
|
||||
<input
|
||||
max="59"
|
||||
min="0"
|
||||
step="{{ $getMinutesStep() }}"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
x-model.debounce="minute"
|
||||
/>
|
||||
|
||||
@if ($hasSeconds)
|
||||
<span
|
||||
class="fi-fo-date-time-picker-time-input-separator"
|
||||
>
|
||||
:
|
||||
</span>
|
||||
|
||||
<input
|
||||
max="59"
|
||||
min="0"
|
||||
step="{{ $getSecondsStep() }}"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
x-model.debounce="second"
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::input.wrapper>
|
||||
|
||||
@if ($datalistOptions)
|
||||
<datalist id="{{ $id }}-list">
|
||||
@foreach ($datalistOptions as $option)
|
||||
<option value="{{ $option }}" />
|
||||
@endforeach
|
||||
</datalist>
|
||||
@endif
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,197 @@
|
||||
@php
|
||||
use Filament\Support\Enums\VerticalAlignment;
|
||||
@endphp
|
||||
|
||||
@props([
|
||||
'areHtmlErrorMessagesAllowed' => null,
|
||||
'errorMessage' => null,
|
||||
'errorMessages' => null,
|
||||
'field' => null,
|
||||
'hasErrors' => true,
|
||||
'hasInlineLabel' => null,
|
||||
'hasNestedRecursiveValidationRules' => null,
|
||||
'id' => null,
|
||||
'inlineLabelVerticalAlignment' => VerticalAlignment::Start,
|
||||
'isDisabled' => null,
|
||||
'label' => null,
|
||||
'labelPrefix' => null,
|
||||
'labelSrOnly' => null,
|
||||
'labelSuffix' => null,
|
||||
'labelTag' => 'label',
|
||||
'required' => null,
|
||||
'shouldShowAllValidationMessages' => null,
|
||||
'statePath' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
if ($field) {
|
||||
$hasInlineLabel ??= $field->hasInlineLabel();
|
||||
$hasNestedRecursiveValidationRules ??= $field instanceof \Filament\Forms\Components\Contracts\HasNestedRecursiveValidationRules;
|
||||
$id ??= $field->getId();
|
||||
$isDisabled ??= $field->isDisabled();
|
||||
$label ??= $field->getLabel();
|
||||
$labelSrOnly ??= $field->isLabelHidden();
|
||||
$required ??= $field->isMarkedAsRequired();
|
||||
$statePath ??= $field->getStatePath();
|
||||
$areHtmlErrorMessagesAllowed ??= $field->areHtmlValidationMessagesAllowed();
|
||||
$shouldShowAllValidationMessages ??= $field->shouldShowAllValidationMessages();
|
||||
}
|
||||
|
||||
$aboveLabelSchema = $field?->getChildSchema($field::ABOVE_LABEL_SCHEMA_KEY)?->toHtmlString();
|
||||
$belowLabelSchema = $field?->getChildSchema($field::BELOW_LABEL_SCHEMA_KEY)?->toHtmlString();
|
||||
$beforeLabelSchema = $field?->getChildSchema($field::BEFORE_LABEL_SCHEMA_KEY)?->toHtmlString();
|
||||
$afterLabelSchema = $field?->getChildSchema($field::AFTER_LABEL_SCHEMA_KEY)?->toHtmlString();
|
||||
$aboveContentSchema = $field?->getChildSchema($field::ABOVE_CONTENT_SCHEMA_KEY)?->toHtmlString();
|
||||
$belowContentSchema = $field?->getChildSchema($field::BELOW_CONTENT_SCHEMA_KEY)?->toHtmlString();
|
||||
$beforeContentSchema = $field?->getChildSchema($field::BEFORE_CONTENT_SCHEMA_KEY)?->toHtmlString();
|
||||
$afterContentSchema = $field?->getChildSchema($field::AFTER_CONTENT_SCHEMA_KEY)?->toHtmlString();
|
||||
$aboveErrorMessageSchema = $field?->getChildSchema($field::ABOVE_ERROR_MESSAGE_SCHEMA_KEY)?->toHtmlString();
|
||||
$belowErrorMessageSchema = $field?->getChildSchema($field::BELOW_ERROR_MESSAGE_SCHEMA_KEY)?->toHtmlString();
|
||||
|
||||
$hasError = $hasErrors && (filled($errorMessage) || filled($errorMessages) || (filled($statePath) && ($errors->has($statePath) || ($hasNestedRecursiveValidationRules && $errors->has("{$statePath}.*")))));
|
||||
|
||||
if ($hasError && filled($statePath) && blank($errorMessage) && blank($errorMessages)) {
|
||||
if ($shouldShowAllValidationMessages) {
|
||||
$errorMessages = $errors->has($statePath) ? $errors->get($statePath) : ($hasNestedRecursiveValidationRules ? $errors->get("{$statePath}.*") : []);
|
||||
|
||||
if (count($errorMessages) === 1) {
|
||||
$errorMessage = Arr::first($errorMessages);
|
||||
$errorMessages = [];
|
||||
}
|
||||
} else {
|
||||
$errorMessage = $errors->has($statePath) ? $errors->first($statePath) : ($hasNestedRecursiveValidationRules ? $errors->first("{$statePath}.*") : null);
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-field-wrapper
|
||||
{{
|
||||
$attributes
|
||||
->merge($field?->getExtraFieldWrapperAttributes() ?? [], escape: false)
|
||||
->class([
|
||||
'fi-fo-field',
|
||||
'fi-fo-field-has-inline-label' => $hasInlineLabel,
|
||||
])
|
||||
}}
|
||||
>
|
||||
@if (filled($label) && $labelSrOnly)
|
||||
<{{ $labelTag }}
|
||||
@if ($labelTag === 'label')
|
||||
for="{{ $id }}"
|
||||
@else
|
||||
id="{{ $id }}-label"
|
||||
@endif
|
||||
class="fi-fo-field-label fi-sr-only"
|
||||
>
|
||||
{{ $label }}
|
||||
</{{ $labelTag }}>
|
||||
@endif
|
||||
|
||||
@if ((filled($label) && (! $labelSrOnly)) || $hasInlineLabel || $aboveLabelSchema || $belowLabelSchema || $beforeLabelSchema || $afterLabelSchema || $labelPrefix || $labelSuffix)
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-field-label-col',
|
||||
"fi-vertical-align-{$inlineLabelVerticalAlignment->value}" => $hasInlineLabel,
|
||||
])
|
||||
>
|
||||
{{ $aboveLabelSchema }}
|
||||
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-field-label-ctn',
|
||||
($label instanceof \Illuminate\View\ComponentSlot) ? $label->attributes->get('class') : null,
|
||||
])
|
||||
>
|
||||
{{ $beforeLabelSchema }}
|
||||
|
||||
@if ((filled($label) && (! $labelSrOnly)) || $labelPrefix || $labelSuffix)
|
||||
<{{ $labelTag }}
|
||||
@if ($labelTag === 'label')
|
||||
for="{{ $id }}"
|
||||
@else
|
||||
id="{{ $id }}-label"
|
||||
@endif
|
||||
class="fi-fo-field-label"
|
||||
>
|
||||
{{ $labelPrefix }}
|
||||
|
||||
@if (filled($label) && (! $labelSrOnly))
|
||||
<span class="fi-fo-field-label-content">
|
||||
{{ $label }}@if ($required && (! $isDisabled))<sup class="fi-fo-field-label-required-mark">*</sup>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
|
||||
{{ $labelSuffix }}
|
||||
</{{ $labelTag }}>
|
||||
@endif
|
||||
|
||||
{{ $afterLabelSchema }}
|
||||
</div>
|
||||
|
||||
{{ $belowLabelSchema }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ((! \Filament\Support\is_slot_empty($slot)) || $hasError || $aboveContentSchema || $belowContentSchema || $beforeContentSchema || $afterContentSchema || $aboveErrorMessageSchema || $belowErrorMessageSchema)
|
||||
<div class="fi-fo-field-content-col">
|
||||
{{ $aboveContentSchema }}
|
||||
|
||||
@if ($beforeContentSchema || $afterContentSchema)
|
||||
<div class="fi-fo-field-content-ctn">
|
||||
{{ $beforeContentSchema }}
|
||||
|
||||
<div class="fi-fo-field-content">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
{{ $afterContentSchema }}
|
||||
</div>
|
||||
@else
|
||||
{{ $slot }}
|
||||
@endif
|
||||
|
||||
{{ $belowContentSchema }}
|
||||
|
||||
@if ($hasError)
|
||||
{{ $aboveErrorMessageSchema }}
|
||||
|
||||
@if (filled($errorMessages))
|
||||
<ul
|
||||
data-validation-error
|
||||
class="fi-fo-field-wrp-error-list"
|
||||
>
|
||||
@foreach ($errorMessages as $errorMessage)
|
||||
<li class="fi-fo-field-wrp-error-message">
|
||||
@if ($areHtmlErrorMessagesAllowed)
|
||||
{!! $errorMessage !!}
|
||||
@else
|
||||
{{ $errorMessage }}
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@elseif ($areHtmlErrorMessagesAllowed)
|
||||
<div
|
||||
data-validation-error
|
||||
class="fi-fo-field-wrp-error-message"
|
||||
>
|
||||
{!! $errorMessage !!}
|
||||
</div>
|
||||
@else
|
||||
<p
|
||||
data-validation-error
|
||||
class="fi-fo-field-wrp-error-message"
|
||||
>
|
||||
{{ $errorMessage }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
{{ $belowErrorMessageSchema }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,377 @@
|
||||
@php
|
||||
use Filament\Support\Enums\Alignment;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$id = $getId();
|
||||
$automaticallyCropImagesAspectRatio = $getAutomaticallyCropImagesAspectRatio();
|
||||
$automaticallyResizeImagesHeight = $getAutomaticallyResizeImagesHeight();
|
||||
$automaticallyResizeImagesWidth = $getAutomaticallyResizeImagesWidth();
|
||||
$isAvatar = $isAvatar();
|
||||
$isMultiple = $isMultiple();
|
||||
$key = $getKey();
|
||||
$statePath = $getStatePath();
|
||||
$isDisabled = $isDisabled();
|
||||
$hasImageEditor = $hasImageEditor();
|
||||
$isImageEditorExplicitlyEnabled = $isImageEditorExplicitlyEnabled();
|
||||
$hasCircleCropper = $hasCircleCropper();
|
||||
$livewireKey = $getLivewireKey();
|
||||
|
||||
$alignment = $getAlignment() ?? Alignment::Start;
|
||||
|
||||
if (! $alignment instanceof Alignment) {
|
||||
$alignment = filled($alignment) ? (Alignment::tryFrom($alignment) ?? $alignment) : null;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
label-tag="div"
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('file-upload', 'filament/forms') }}"
|
||||
x-data="fileUploadFormComponent({
|
||||
acceptedFileTypes: @js($getAcceptedFileTypes()),
|
||||
automaticallyCropImagesAspectRatio: @js($automaticallyCropImagesAspectRatio),
|
||||
automaticallyOpenImageEditorForAspectRatio: @js($getAutomaticallyOpenImageEditorForAspectRatio()),
|
||||
automaticallyResizeImagesMode: @js($getAutomaticallyResizeImagesMode()),
|
||||
automaticallyResizeImagesHeight: @js($automaticallyResizeImagesHeight),
|
||||
automaticallyResizeImagesWidth: @js($automaticallyResizeImagesWidth),
|
||||
cancelUploadUsing: (fileKey) => {
|
||||
$wire.cancelUpload(`{{ $statePath }}.${fileKey}`)
|
||||
},
|
||||
canEditSvgs: @js($canEditSvgs()),
|
||||
confirmSvgEditingMessage: @js(__('filament-forms::components.file_upload.editor.svg.messages.confirmation')),
|
||||
deleteUploadedFileUsing: async (fileKey) => {
|
||||
return await $wire.callSchemaComponentMethod(
|
||||
@js($key),
|
||||
'deleteUploadedFile',
|
||||
{ fileKey },
|
||||
)
|
||||
},
|
||||
disabledSvgEditingMessage: @js(__('filament-forms::components.file_upload.editor.svg.messages.disabled')),
|
||||
getUploadedFilesUsing: async () => {
|
||||
return await Livewire.fireAction(
|
||||
$wire.__instance,
|
||||
'callSchemaComponentMethod',
|
||||
[@js($key), 'getUploadedFiles'],
|
||||
{ async: true },
|
||||
)
|
||||
},
|
||||
hasCircleCropper: @js($hasCircleCropper),
|
||||
hasImageEditor: @js($hasImageEditor),
|
||||
imageEditorEmptyFillColor: @js($getImageEditorEmptyFillColor()),
|
||||
imageEditorMode: @js($getImageEditorMode()),
|
||||
imageEditorViewportHeight: @js($getImageEditorViewportHeight()),
|
||||
imageEditorViewportWidth: @js($getImageEditorViewportWidth()),
|
||||
imagePreviewHeight: @js($getImagePreviewHeight()),
|
||||
isAvatar: @js($isAvatar),
|
||||
isDeletable: @js($isDeletable()),
|
||||
isDisabled: @js($isDisabled),
|
||||
isDownloadable: @js($isDownloadable()),
|
||||
isImageEditorExplicitlyEnabled: @js($isImageEditorExplicitlyEnabled),
|
||||
isMultiple: @js($isMultiple),
|
||||
isOpenable: @js($isOpenable()),
|
||||
isPasteable: @js($isPasteable()),
|
||||
isPreviewable: @js($isPreviewable()),
|
||||
isReorderable: @js($isReorderable()),
|
||||
isSvgEditingConfirmed: @js($isSvgEditingConfirmed()),
|
||||
itemPanelAspectRatio: @js($getItemPanelAspectRatio()),
|
||||
loadingIndicatorPosition: @js($getLoadingIndicatorPosition()),
|
||||
locale: @js(app()->getLocale()),
|
||||
maxFiles: @js($maxFiles = $getMaxFiles()),
|
||||
maxFilesValidationMessage: @js($maxFiles ? trans_choice('validation.max.array', $maxFiles, ['attribute' => $getValidationAttribute(), 'max' => $maxFiles]) : null),
|
||||
maxParallelUploads: @js($getMaxParallelUploads()),
|
||||
maxSize: @js(($size = $getMaxSize()) ? "{$size}KB" : null),
|
||||
mimeTypeMap: @js($getMimeTypeMap()),
|
||||
minSize: @js(($size = $getMinSize()) ? "{$size}KB" : null),
|
||||
panelAspectRatio: @js($getPanelAspectRatio()),
|
||||
panelLayout: @js($getPanelLayout()),
|
||||
placeholder: @js($getPlaceholder()),
|
||||
removeUploadedFileButtonPosition: @js($getRemoveUploadedFileButtonPosition()),
|
||||
removeUploadedFileUsing: async (fileKey) => {
|
||||
return await $wire.callSchemaComponentMethod(
|
||||
@js($key),
|
||||
'removeUploadedFile',
|
||||
{ fileKey },
|
||||
)
|
||||
},
|
||||
reorderUploadedFilesUsing: async (fileKeys) => {
|
||||
return await $wire.callSchemaComponentMethod(
|
||||
@js($key),
|
||||
'reorderUploadedFiles',
|
||||
{ fileKeys },
|
||||
)
|
||||
},
|
||||
shouldAppendFiles: @js($shouldAppendFiles()),
|
||||
shouldAutomaticallyUpscaleImagesWhenResizing: @js($shouldAutomaticallyUpscaleImagesWhenResizing()),
|
||||
shouldOrientImageFromExif: @js($shouldOrientImagesFromExif()),
|
||||
shouldTransformImage: @js($automaticallyCropImagesAspectRatio || $automaticallyResizeImagesHeight || $automaticallyResizeImagesWidth),
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
|
||||
uploadButtonPosition: @js($getUploadButtonPosition()),
|
||||
uploadingMessage: @js($getUploadingMessage()),
|
||||
uploadProgressIndicatorPosition: @js($getUploadProgressIndicatorPosition()),
|
||||
uploadUsing: (fileKey, file, success, error, progress) => {
|
||||
$wire.upload(
|
||||
`{{ $statePath }}.${fileKey}`,
|
||||
file,
|
||||
() => {
|
||||
success(fileKey)
|
||||
},
|
||||
error,
|
||||
(progressEvent) => {
|
||||
progress(true, progressEvent.detail.progress, 100)
|
||||
},
|
||||
)
|
||||
},
|
||||
})"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.{{
|
||||
substr(md5(serialize([
|
||||
$isDisabled,
|
||||
])), 0, 64)
|
||||
}}"
|
||||
{{
|
||||
$attributes
|
||||
->merge([
|
||||
'aria-labelledby' => "{$id}-label",
|
||||
'id' => $id,
|
||||
'role' => 'group',
|
||||
], escape: false)
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->merge($getExtraAlpineAttributes(), escape: false)
|
||||
->class([
|
||||
'fi-fo-file-upload',
|
||||
'fi-fo-file-upload-avatar' => $isAvatar,
|
||||
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : $alignment,
|
||||
])
|
||||
}}
|
||||
>
|
||||
<div class="fi-fo-file-upload-input-ctn">
|
||||
<input
|
||||
x-ref="input"
|
||||
{{
|
||||
$getExtraInputAttributeBag()
|
||||
->merge([
|
||||
'aria-labelledby' => "{$id}-label",
|
||||
'disabled' => $isDisabled,
|
||||
'multiple' => $isMultiple,
|
||||
'type' => 'file',
|
||||
], escape: false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="error"
|
||||
x-text="error"
|
||||
x-cloak
|
||||
class="fi-fo-file-upload-error-message"
|
||||
></div>
|
||||
|
||||
@if ($hasImageEditor && (! $isDisabled))
|
||||
<div
|
||||
x-show="isEditorOpen"
|
||||
x-cloak
|
||||
x-on:click.stop=""
|
||||
x-trap.noscroll="isEditorOpen"
|
||||
x-on:keydown.escape.prevent.stop="closeEditor"
|
||||
@class([
|
||||
'fi-fo-file-upload-editor',
|
||||
'fi-fo-file-upload-editor-circle-cropper' => $hasCircleCropper,
|
||||
'fi-fo-file-upload-editor-crop-only' => ! $isImageEditorExplicitlyEnabled,
|
||||
])
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="fi-fo-file-upload-editor-overlay"
|
||||
></div>
|
||||
|
||||
<div class="fi-fo-file-upload-editor-window">
|
||||
<div class="fi-fo-file-upload-editor-image-ctn">
|
||||
<img
|
||||
x-ref="editor"
|
||||
class="fi-fo-file-upload-editor-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fi-fo-file-upload-editor-control-panel">
|
||||
@if ($isImageEditorExplicitlyEnabled)
|
||||
<div
|
||||
class="fi-fo-file-upload-editor-control-panel-main"
|
||||
>
|
||||
<div
|
||||
class="fi-fo-file-upload-editor-control-panel-group"
|
||||
>
|
||||
@foreach ([
|
||||
[
|
||||
'label' => __('filament-forms::components.file_upload.editor.fields.x_position.label'),
|
||||
'ref' => 'xPositionInput',
|
||||
'unit' => __('filament-forms::components.file_upload.editor.fields.x_position.unit'),
|
||||
'alpineSaveHandler' => 'editor.setData({...editor.getData(true), x: +$el.value})',
|
||||
],
|
||||
[
|
||||
'label' => __('filament-forms::components.file_upload.editor.fields.y_position.label'),
|
||||
'ref' => 'yPositionInput',
|
||||
'unit' => __('filament-forms::components.file_upload.editor.fields.y_position.unit'),
|
||||
'alpineSaveHandler' => 'editor.setData({...editor.getData(true), y: +$el.value})',
|
||||
],
|
||||
[
|
||||
'label' => __('filament-forms::components.file_upload.editor.fields.width.label'),
|
||||
'ref' => 'widthInput',
|
||||
'unit' => __('filament-forms::components.file_upload.editor.fields.width.unit'),
|
||||
'alpineSaveHandler' => 'editor.setData({...editor.getData(true), width: +$el.value})',
|
||||
],
|
||||
[
|
||||
'label' => __('filament-forms::components.file_upload.editor.fields.height.label'),
|
||||
'ref' => 'heightInput',
|
||||
'unit' => __('filament-forms::components.file_upload.editor.fields.height.unit'),
|
||||
'alpineSaveHandler' => 'editor.setData({...editor.getData(true), height: +$el.value})',
|
||||
],
|
||||
[
|
||||
'label' => __('filament-forms::components.file_upload.editor.fields.rotation.label'),
|
||||
'ref' => 'rotationInput',
|
||||
'unit' => __('filament-forms::components.file_upload.editor.fields.rotation.unit'),
|
||||
'alpineSaveHandler' => 'editor.rotateTo(+$el.value)',
|
||||
],
|
||||
] as $input)
|
||||
<label>
|
||||
<x-filament::input.wrapper>
|
||||
<x-slot name="prefix">
|
||||
{{ $input['label'] }}
|
||||
</x-slot>
|
||||
|
||||
<input
|
||||
x-on:keyup.enter.prevent.stop="editor && {!! $input['alpineSaveHandler'] !!}"
|
||||
x-on:blur="editor && {!! $input['alpineSaveHandler'] !!}"
|
||||
x-ref="{{ $input['ref'] }}"
|
||||
x-on:keydown.enter.prevent
|
||||
type="text"
|
||||
class="fi-input"
|
||||
/>
|
||||
|
||||
<x-slot name="suffix">
|
||||
{{ $input['unit'] }}
|
||||
</x-slot>
|
||||
</x-filament::input.wrapper>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="fi-fo-file-upload-editor-control-panel-group"
|
||||
>
|
||||
@foreach ($getImageEditorActions() as $groupedActions)
|
||||
<div class="fi-btn-group">
|
||||
@foreach ($groupedActions as $action)
|
||||
<button
|
||||
aria-label="{{ $action['label'] }}"
|
||||
type="button"
|
||||
x-on:click.prevent.stop="{{ $action['alpineClickHandler'] }}"
|
||||
x-tooltip="{ content: @js($action['label']), theme: $store.theme }"
|
||||
class="fi-btn"
|
||||
>
|
||||
{{ $action['iconHtml'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (count($aspectRatios = $getImageEditorAspectRatioOptionsForJs()))
|
||||
<div
|
||||
class="fi-fo-file-upload-editor-control-panel-group"
|
||||
>
|
||||
<div
|
||||
class="fi-fo-file-upload-editor-control-panel-group-title"
|
||||
>
|
||||
{{ __('filament-forms::components.file_upload.editor.aspect_ratios.label') }}
|
||||
</div>
|
||||
|
||||
@foreach (collect($aspectRatios)->chunk(5) as $ratiosChunk)
|
||||
<div class="fi-btn-group">
|
||||
@foreach ($ratiosChunk as $label => $ratio)
|
||||
<button
|
||||
type="button"
|
||||
x-on:click.prevent.stop="
|
||||
currentRatio = @js($label) {!! ';' !!}
|
||||
editor.setAspectRatio(@js($ratio))
|
||||
"
|
||||
x-tooltip="{ content: @js(__('filament-forms::components.file_upload.editor.actions.set_aspect_ratio.label', ['ratio' => $label])), theme: $store.theme }"
|
||||
x-bind:class="{ 'fi-active': currentRatio === @js($label) }"
|
||||
class="fi-btn"
|
||||
>
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="fi-fo-file-upload-editor-control-panel-footer"
|
||||
>
|
||||
@if ($isImageEditorExplicitlyEnabled)
|
||||
<button
|
||||
type="button"
|
||||
x-on:click.prevent="pond.imageEditEditor.oncancel"
|
||||
class="fi-btn"
|
||||
>
|
||||
{{ __('filament-forms::components.file_upload.editor.actions.cancel.label') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-on:click.prevent.stop="editor.reset()"
|
||||
{{
|
||||
(new \Illuminate\View\ComponentAttributeBag)
|
||||
->color(\Filament\Support\View\Components\ButtonComponent::class, 'danger')
|
||||
->class(['fi-btn fi-fo-file-upload-editor-control-panel-reset-action'])
|
||||
}}
|
||||
>
|
||||
{{ __('filament-forms::components.file_upload.editor.actions.reset.label') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-on:click.prevent="saveEditor"
|
||||
{{
|
||||
(new \Illuminate\View\ComponentAttributeBag)
|
||||
->color(\Filament\Support\View\Components\ButtonComponent::class, 'success')
|
||||
->class(['fi-btn'])
|
||||
}}
|
||||
>
|
||||
{{ __('filament-forms::components.file_upload.editor.actions.save.label') }}
|
||||
</button>
|
||||
@else
|
||||
<button
|
||||
type="button"
|
||||
x-on:click.prevent="saveEditor"
|
||||
{{
|
||||
(new \Illuminate\View\ComponentAttributeBag)
|
||||
->color(\Filament\Support\View\Components\ButtonComponent::class, 'success')
|
||||
->class(['fi-btn'])
|
||||
}}
|
||||
>
|
||||
{{ __('filament-forms::components.file_upload.editor.actions.save.label') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-on:click.prevent="pond.imageEditEditor.oncancel"
|
||||
class="fi-btn"
|
||||
>
|
||||
{{ __('filament-forms::components.file_upload.editor.actions.cancel.label') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,12 @@
|
||||
<input
|
||||
{{
|
||||
$attributes
|
||||
->merge([
|
||||
'id' => $getId(),
|
||||
'type' => 'hidden',
|
||||
$applyStateBindingModifiers('wire:model') => $getStatePath(),
|
||||
], escape: false)
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->class(['fi-fo-hidden'])
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,148 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributeBag = $getExtraAttributeBag();
|
||||
$canEditKeys = $canEditKeys();
|
||||
$canEditValues = $canEditValues();
|
||||
$keyPlaceholder = $getKeyPlaceholder();
|
||||
$valuePlaceholder = $getValuePlaceholder();
|
||||
$debounce = $getLiveDebounce();
|
||||
$isAddable = $isAddable();
|
||||
$isDeletable = $isDeletable();
|
||||
$isDisabled = $isDisabled();
|
||||
$isReorderable = $isReorderable();
|
||||
$statePath = $getStatePath();
|
||||
$livewireKey = $getLivewireKey();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
class="fi-fo-key-value-wrp"
|
||||
>
|
||||
<x-filament::input.wrapper
|
||||
:disabled="$isDisabled"
|
||||
:valid="! $errors->has($statePath)"
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes($extraAttributeBag)
|
||||
->class(['fi-fo-key-value'])
|
||||
"
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('key-value', 'filament/forms') }}"
|
||||
x-data="keyValueFormComponent({
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
|
||||
})"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.{{
|
||||
substr(md5(serialize([
|
||||
$isDisabled,
|
||||
])), 0, 64)
|
||||
}}"
|
||||
{{
|
||||
$attributes
|
||||
->merge($getExtraAlpineAttributes(), escape: false)
|
||||
->class(['fi-fo-key-value-table-ctn'])
|
||||
}}
|
||||
>
|
||||
<table class="fi-fo-key-value-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@if ($isReorderable && (! $isDisabled))
|
||||
<th
|
||||
scope="col"
|
||||
x-show="rows.length"
|
||||
class="fi-has-action"
|
||||
></th>
|
||||
@endif
|
||||
|
||||
<th scope="col">
|
||||
{{ $getKeyLabel() }}
|
||||
</th>
|
||||
|
||||
<th scope="col">
|
||||
{{ $getValueLabel() }}
|
||||
</th>
|
||||
|
||||
@if ($isDeletable && (! $isDisabled))
|
||||
<th
|
||||
scope="col"
|
||||
x-show="rows.length"
|
||||
class="fi-has-action"
|
||||
></th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody
|
||||
@if ($isReorderable)
|
||||
x-on:end.stop="reorderRows($event)"
|
||||
x-sortable
|
||||
data-sortable-animation-duration="{{ $getReorderAnimationDuration() }}"
|
||||
@endif
|
||||
>
|
||||
<template
|
||||
x-bind:key="index"
|
||||
x-for="(row, index) in rows"
|
||||
>
|
||||
<tr
|
||||
@if ($isReorderable)
|
||||
x-bind:x-sortable-item="row.key"
|
||||
@endif
|
||||
>
|
||||
@if ($isReorderable && (! $isDisabled))
|
||||
<td class="fi-has-action">
|
||||
<div
|
||||
x-sortable-handle
|
||||
class="fi-fo-key-value-table-row-sortable-handle"
|
||||
>
|
||||
{{ $getAction('reorder') }}
|
||||
</div>
|
||||
</td>
|
||||
@endif
|
||||
|
||||
<td>
|
||||
<input
|
||||
@disabled((! $canEditKeys) || $isDisabled)
|
||||
placeholder="{{ $keyPlaceholder }}"
|
||||
type="text"
|
||||
x-model="row.key"
|
||||
x-on:input.debounce.{{ $debounce ?? '500ms' }}="updateState"
|
||||
class="fi-input"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<input
|
||||
@disabled((! $canEditValues) || $isDisabled)
|
||||
placeholder="{{ $valuePlaceholder }}"
|
||||
type="text"
|
||||
x-model="row.value"
|
||||
x-on:input.debounce.{{ $debounce ?? '500ms' }}="updateState"
|
||||
class="fi-input"
|
||||
/>
|
||||
</td>
|
||||
|
||||
@if ($isDeletable && (! $isDisabled))
|
||||
<td class="fi-has-action">
|
||||
<div x-on:click="deleteRow(index)">
|
||||
{{ $getAction('delete') }}
|
||||
</div>
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if ($isAddable && (! $isDisabled))
|
||||
<div
|
||||
x-on:click="addRow"
|
||||
class="fi-fo-key-value-add-action-ctn"
|
||||
>
|
||||
{{ $getAction('add') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::input.wrapper>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,30 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributes = $getExtraAttributes();
|
||||
$id = $getId();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@if (filled($id) || filled($extraAttributes))
|
||||
{!! '<div' !!}
|
||||
{{-- Avoid formatting issues with unclosed elements --}}
|
||||
{{
|
||||
$attributes
|
||||
->merge([
|
||||
'id' => $id,
|
||||
], escape: false)
|
||||
->merge($extraAttributes, escape: false)
|
||||
}}
|
||||
>
|
||||
@endif
|
||||
|
||||
@if (filled($key = $getLivewireKey()))
|
||||
@livewire($getComponent(), $getComponentProperties(), key($key))
|
||||
@else
|
||||
@livewire($getComponent(), $getComponentProperties())
|
||||
@endif
|
||||
@if (filled($id) || filled($extraAttributes))
|
||||
{!! '</div>' !!}
|
||||
{{-- Avoid formatting issues with unclosed elements --}}
|
||||
@endif
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,79 @@
|
||||
@php
|
||||
$id = $getId();
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributeBag = $getExtraAttributeBag();
|
||||
$key = $getKey();
|
||||
$label = $getLabel();
|
||||
$statePath = $getStatePath();
|
||||
$fileAttachmentsMaxSize = $getFileAttachmentsMaxSize();
|
||||
$fileAttachmentsAcceptedFileTypes = $getFileAttachmentsAcceptedFileTypes();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
@if ($isDisabled())
|
||||
<div id="{{ $id }}" class="fi-fo-markdown-editor fi-disabled fi-prose">
|
||||
{!! str($getState())->markdown($getCommonMarkOptions(), $getCommonMarkExtensions())->sanitizeHtml() !!}
|
||||
</div>
|
||||
@else
|
||||
<x-filament::input.wrapper
|
||||
:valid="! $errors->has($statePath)"
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes($extraAttributeBag)
|
||||
->class(['fi-fo-markdown-editor'])
|
||||
"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="{{ $id }}-label"
|
||||
id="{{ $id }}"
|
||||
role="group"
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('markdown-editor', 'filament/forms') }}"
|
||||
x-data="markdownEditorFormComponent({
|
||||
canAttachFiles: @js($hasFileAttachments()),
|
||||
isLiveDebounced: @js($isLiveDebounced()),
|
||||
isLiveOnBlur: @js($isLiveOnBlur()),
|
||||
label: @js($label),
|
||||
liveDebounce: @js($getNormalizedLiveDebounce()),
|
||||
maxHeight: @js($getMaxHeight()),
|
||||
minHeight: @js($getMinHeight()),
|
||||
placeholder: @js($getPlaceholder()),
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')", isOptimisticallyLive: false) }},
|
||||
toolbarButtons: @js($getToolbarButtons()),
|
||||
translations: @js(__('filament-forms::components.markdown_editor')),
|
||||
uploadFileAttachmentUsing: async (file, onSuccess, onError) => {
|
||||
const acceptedTypes = @js($fileAttachmentsAcceptedFileTypes)
|
||||
|
||||
if (acceptedTypes && ! acceptedTypes.includes(file.type)) {
|
||||
return onError(@js($fileAttachmentsAcceptedFileTypes ? __('filament-forms::components.markdown_editor.file_attachments_accepted_file_types_message', ['values' => implode(', ', $fileAttachmentsAcceptedFileTypes)]) : null))
|
||||
}
|
||||
|
||||
const maxSize = @js($fileAttachmentsMaxSize)
|
||||
|
||||
if (maxSize && file.size > +maxSize * 1024) {
|
||||
return onError(@js($fileAttachmentsMaxSize ? trans_choice('filament-forms::components.markdown_editor.file_attachments_max_size_message', $fileAttachmentsMaxSize, ['max' => $fileAttachmentsMaxSize]) : null))
|
||||
}
|
||||
|
||||
$wire.upload(`componentFileAttachments.{{ $statePath }}`, file, () => {
|
||||
$wire
|
||||
.callSchemaComponentMethod(
|
||||
'{{ $key }}',
|
||||
'saveUploadedFileAttachmentAndGetUrl',
|
||||
)
|
||||
.then((url) => {
|
||||
if (! url) {
|
||||
return onError()
|
||||
}
|
||||
|
||||
onSuccess(url)
|
||||
})
|
||||
})
|
||||
},
|
||||
})"
|
||||
wire:ignore
|
||||
{{ $getExtraAlpineAttributeBag() }}
|
||||
>
|
||||
<textarea x-ref="editor" x-cloak></textarea>
|
||||
</div>
|
||||
</x-filament::input.wrapper>
|
||||
@endif
|
||||
</x-dynamic-component>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
@php
|
||||
use Filament\Forms\Components\TableSelect\Livewire\TableSelectLivewireComponent;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributes = $getExtraAttributes();
|
||||
$id = $getId();
|
||||
$isDisabled = $isDisabled();
|
||||
$isMultiple = $isMultiple();
|
||||
$hasBadges = $hasBadges();
|
||||
$badgeColor = $getBadgeColor();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
{{
|
||||
$attributes
|
||||
->merge([
|
||||
'id' => $id,
|
||||
], escape: false)
|
||||
->merge($extraAttributes, escape: false)
|
||||
->class([
|
||||
'fi-fo-modal-table-select',
|
||||
'fi-fo-modal-table-select-disabled' => $isDisabled,
|
||||
'fi-fo-modal-table-select-multiple' => $isMultiple,
|
||||
])
|
||||
}}
|
||||
>
|
||||
@if (((! $isMultiple) && filled($optionLabel = $getOptionLabel())) ||
|
||||
($isMultiple && filled($optionLabels = $getOptionLabels())))
|
||||
@if ($isMultiple && $hasBadges)
|
||||
<div class="fi-fo-modal-table-select-badges-ctn">
|
||||
@foreach ($optionLabels as $optionLabel)
|
||||
@if ($hasBadges)
|
||||
<x-filament::badge :color="$badgeColor">
|
||||
{{ $optionLabel }}
|
||||
</x-filament::badge>
|
||||
@else
|
||||
{{ $optionLabel }}
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div>
|
||||
@if ($hasBadges)
|
||||
<x-filament::badge :color="$badgeColor">
|
||||
{{ $optionLabel }}
|
||||
</x-filament::badge>
|
||||
@elseif ($isMultiple)
|
||||
@foreach ($optionLabels as $optionLabel)
|
||||
{{ $optionLabel . ($loop->last ? '' : ', ') }}
|
||||
@endforeach
|
||||
@else
|
||||
{{ $optionLabel }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@elseif (filled($placeholder = $getPlaceholder()))
|
||||
<div class="fi-fo-modal-table-select-placeholder">
|
||||
{{ $placeholder }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! $isDisabled)
|
||||
<div>
|
||||
{{ $getAction('select') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$placeholder = $getPlaceholder();
|
||||
$extraAttributes = $getExtraAttributeBag()
|
||||
->merge($getExtraAlpineAttributes(), escape: false);
|
||||
$extraInputAttributes = $getExtraInputAttributeBag()
|
||||
->merge([
|
||||
'autocomplete' => false,
|
||||
'autofocus' => $isAutofocused(),
|
||||
'disabled' => $isDisabled(),
|
||||
'id' => $getId(),
|
||||
'length' => $getLength(),
|
||||
'placeholder' => filled($placeholder) ? e($placeholder) : null,
|
||||
'readonly' => $isReadOnly(),
|
||||
'required' => $isRequired() && (! $isConcealed()),
|
||||
$applyStateBindingModifiers('wire:model') => $getStatePath(),
|
||||
], escape: false);
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<x-filament::input.one-time-code
|
||||
:attributes="\Filament\Support\prepare_inherited_attributes($extraAttributes)"
|
||||
>
|
||||
<x-slot
|
||||
name="input"
|
||||
:attributes="\Filament\Support\prepare_inherited_attributes($extraInputAttributes)"
|
||||
></x-slot>
|
||||
</x-filament::input.one-time-code>
|
||||
</x-dynamic-component>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
@props([
|
||||
'field' => null,
|
||||
'id' => null,
|
||||
'label' => null,
|
||||
'labelTag' => 'label',
|
||||
])
|
||||
|
||||
@php
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
if ($field) {
|
||||
$id ??= $field->getId();
|
||||
$label ??= $field->getLabel();
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-field-wrapper
|
||||
{{
|
||||
(new ComponentAttributeBag)
|
||||
->merge($field?->getExtraFieldWrapperAttributes() ?? [], escape: false)
|
||||
->class([
|
||||
'fi-fo-field',
|
||||
])
|
||||
}}
|
||||
>
|
||||
@if (filled($label))
|
||||
<{{ $labelTag }}
|
||||
@if ($labelTag === 'label')
|
||||
for="{{ $id }}"
|
||||
@else
|
||||
id="{{ $id }}-label"
|
||||
@endif
|
||||
class="fi-fo-field-label fi-sr-only"
|
||||
>
|
||||
{{ $label }}
|
||||
</{{ $labelTag }}>
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
@php
|
||||
use Filament\Support\Enums\GridDirection;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraInputAttributeBag = $getExtraInputAttributeBag();
|
||||
$gridDirection = $getGridDirection() ?? GridDirection::Column;
|
||||
$id = $getId();
|
||||
$isDisabled = $isDisabled();
|
||||
$isInline = $isInline();
|
||||
$statePath = $getStatePath();
|
||||
$wireModelAttribute = $applyStateBindingModifiers('wire:model');
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
{{
|
||||
$getExtraAttributeBag()
|
||||
->when(! $isInline, fn (ComponentAttributeBag $attributes) => $attributes->grid($getColumns(), $gridDirection))
|
||||
->class([
|
||||
'fi-fo-radio',
|
||||
'fi-inline' => $isInline,
|
||||
])
|
||||
}}
|
||||
>
|
||||
@foreach ($getOptions() as $value => $label)
|
||||
@php
|
||||
$inputAttributes = $extraInputAttributeBag
|
||||
->merge([
|
||||
'autofocus' => $loop->first && $isAutofocused(),
|
||||
'disabled' => $isDisabled || $isOptionDisabled($value, $label),
|
||||
'id' => $id . '-' . $value,
|
||||
'name' => $id,
|
||||
'value' => $value,
|
||||
$wireModelAttribute => $statePath,
|
||||
], escape: false);
|
||||
@endphp
|
||||
|
||||
<label class="fi-fo-radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
{{
|
||||
$inputAttributes->class([
|
||||
'fi-radio-input',
|
||||
'fi-valid' => ! $errors->has($statePath),
|
||||
'fi-invalid' => $errors->has($statePath),
|
||||
])
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="fi-fo-radio-label-text">
|
||||
<p>
|
||||
{{ $label }}
|
||||
</p>
|
||||
|
||||
@if ($hasDescription($value))
|
||||
<p class="fi-fo-radio-label-description">
|
||||
{{ $getDescription($value) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,264 @@
|
||||
@php
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$items = $getItems();
|
||||
|
||||
$addAction = $getAction($getAddActionName());
|
||||
$addActionAlignment = $getAddActionAlignment();
|
||||
$addBetweenAction = $getAction($getAddBetweenActionName());
|
||||
$cloneAction = $getAction($getCloneActionName());
|
||||
$collapseAllAction = $getAction($getCollapseAllActionName());
|
||||
$expandAllAction = $getAction($getExpandAllActionName());
|
||||
$deleteAction = $getAction($getDeleteActionName());
|
||||
$moveDownAction = $getAction($getMoveDownActionName());
|
||||
$moveUpAction = $getAction($getMoveUpActionName());
|
||||
$reorderAction = $getAction($getReorderActionName());
|
||||
$extraItemActions = $getExtraItemActions();
|
||||
|
||||
$hasItemNumbers = $hasItemNumbers();
|
||||
$hasItemHeaders = $hasItemHeaders();
|
||||
$isAddable = $isAddable();
|
||||
$isCloneable = $isCloneable();
|
||||
$isCollapsible = $isCollapsible();
|
||||
$isDeletable = $isDeletable();
|
||||
$isReorderableWithButtons = $isReorderableWithButtons();
|
||||
$isReorderableWithDragAndDrop = $isReorderableWithDragAndDrop();
|
||||
|
||||
$collapseAllActionIsVisible = $isCollapsible && $collapseAllAction->isVisible();
|
||||
$expandAllActionIsVisible = $isCollapsible && $expandAllAction->isVisible();
|
||||
$persistCollapsed = $shouldPersistCollapsed();
|
||||
|
||||
$key = $getKey();
|
||||
$statePath = $getStatePath();
|
||||
|
||||
$itemLabelHeadingTag = $getHeadingTag();
|
||||
$isItemLabelTruncated = $isItemLabelTruncated();
|
||||
$labelBetweenItems = $getLabelBetweenItems();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
{{
|
||||
$attributes
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->class([
|
||||
'fi-fo-repeater',
|
||||
'fi-collapsible' => $isCollapsible,
|
||||
])
|
||||
}}
|
||||
>
|
||||
@if ($collapseAllActionIsVisible || $expandAllActionIsVisible)
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-repeater-actions',
|
||||
'fi-hidden' => count($items) < 2,
|
||||
])
|
||||
>
|
||||
@if ($collapseAllActionIsVisible)
|
||||
<span
|
||||
x-on:click="$dispatch('repeater-collapse', '{{ $statePath }}')"
|
||||
>
|
||||
{{ $collapseAllAction }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($expandAllActionIsVisible)
|
||||
<span
|
||||
x-on:click="$dispatch('repeater-expand', '{{ $statePath }}')"
|
||||
>
|
||||
{{ $expandAllAction }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (count($items))
|
||||
<ul
|
||||
x-sortable
|
||||
{{
|
||||
(new ComponentAttributeBag)
|
||||
->grid($getGridColumns())
|
||||
->merge([
|
||||
'data-sortable-animation-duration' => $getReorderAnimationDuration(),
|
||||
'x-on:end.stop' => '$wire.mountAction(\'reorder\', { items: $event.target.sortable.toArray() }, { schemaComponent: \'' . $key . '\' })',
|
||||
], escape: false)
|
||||
->class(['fi-fo-repeater-items'])
|
||||
}}
|
||||
>
|
||||
@foreach ($items as $itemKey => $item)
|
||||
@php
|
||||
$itemLabel = $getItemLabel($itemKey);
|
||||
$visibleExtraItemActions = array_filter(
|
||||
$extraItemActions,
|
||||
fn (Action $action): bool => $action(['item' => $itemKey])->isVisible(),
|
||||
);
|
||||
$cloneAction = $cloneAction(['item' => $itemKey]);
|
||||
$cloneActionIsVisible = $isCloneable && $cloneAction->isVisible();
|
||||
$deleteAction = $deleteAction(['item' => $itemKey]);
|
||||
$deleteActionIsVisible = $isDeletable && $deleteAction->isVisible();
|
||||
$moveDownAction = $moveDownAction(['item' => $itemKey])->disabled($loop->last);
|
||||
$moveDownActionIsVisible = $isReorderableWithButtons && $moveDownAction->isVisible();
|
||||
$moveUpAction = $moveUpAction(['item' => $itemKey])->disabled($loop->first);
|
||||
$moveUpActionIsVisible = $isReorderableWithButtons && $moveUpAction->isVisible();
|
||||
$reorderActionIsVisible = $isReorderableWithDragAndDrop && $reorderAction->isVisible();
|
||||
$hasItemHeader = $hasItemHeaders && ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible || filled($itemLabel) || $cloneActionIsVisible || $deleteActionIsVisible || $isCollapsible || $visibleExtraItemActions);
|
||||
@endphp
|
||||
|
||||
<li
|
||||
wire:ignore.self
|
||||
wire:key="{{ $item->getLivewireKey() }}.item"
|
||||
x-data="{
|
||||
isCollapsed: @if ($persistCollapsed) $persist(@js($isCollapsed($item))).as(`repeater-${@js($key)}-${@js($itemKey)}-isCollapsed`) @else @js($isCollapsed($item)) @endif,
|
||||
}"
|
||||
x-on:repeater-expand.window="$event.detail === '{{ $statePath }}' && (isCollapsed = false)"
|
||||
x-on:repeater-collapse.window="$event.detail === '{{ $statePath }}' && (isCollapsed = true)"
|
||||
x-on:expand="isCollapsed = false"
|
||||
x-sortable-item="{{ $itemKey }}"
|
||||
@class([
|
||||
'fi-fo-repeater-item',
|
||||
'fi-fo-repeater-item-has-header' => $hasItemHeader,
|
||||
])
|
||||
x-bind:class="{ 'fi-collapsed': isCollapsed }"
|
||||
>
|
||||
@if ($hasItemHeader)
|
||||
<div
|
||||
@if ($isCollapsible)
|
||||
x-on:click.stop="isCollapsed = !isCollapsed"
|
||||
@endif
|
||||
class="fi-fo-repeater-item-header"
|
||||
>
|
||||
@if ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible)
|
||||
<ul
|
||||
class="fi-fo-repeater-item-header-start-actions"
|
||||
>
|
||||
@if ($reorderActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $reorderAction->extraAttributes(['x-sortable-handle' => true], merge: true) }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($moveUpActionIsVisible || $moveDownActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $moveUpAction }}
|
||||
</li>
|
||||
|
||||
<li x-on:click.stop>
|
||||
{{ $moveDownAction }}
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@if (filled($itemLabel))
|
||||
<{{ $itemLabelHeadingTag }}
|
||||
@class([
|
||||
'fi-fo-repeater-item-header-label',
|
||||
'fi-truncated' => $isItemLabelTruncated,
|
||||
])
|
||||
>
|
||||
{{ $itemLabel }}
|
||||
|
||||
@if ($hasItemNumbers)
|
||||
{{ $loop->iteration }}
|
||||
@endif
|
||||
</{{ $itemLabelHeadingTag }}>
|
||||
@endif
|
||||
|
||||
@if ($cloneActionIsVisible || $deleteActionIsVisible || $isCollapsible || $visibleExtraItemActions)
|
||||
<ul
|
||||
class="fi-fo-repeater-item-header-end-actions"
|
||||
>
|
||||
@foreach ($visibleExtraItemActions as $extraItemAction)
|
||||
<li x-on:click.stop>
|
||||
{{ $extraItemAction(['item' => $itemKey]) }}
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@if ($cloneActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $cloneAction }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($deleteActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $deleteAction }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($isCollapsible)
|
||||
<li
|
||||
class="fi-fo-repeater-item-header-collapsible-actions"
|
||||
x-on:click.stop="isCollapsed = !isCollapsed"
|
||||
>
|
||||
<div
|
||||
class="fi-fo-repeater-item-header-collapse-action"
|
||||
>
|
||||
{{ $getAction('collapse') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="fi-fo-repeater-item-header-expand-action"
|
||||
>
|
||||
{{ $getAction('expand') }}
|
||||
</div>
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
x-show="! isCollapsed"
|
||||
class="fi-fo-repeater-item-content"
|
||||
>
|
||||
{{ $item }}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (! $loop->last)
|
||||
@if ($isAddable && $addBetweenAction(['afterItem' => $itemKey])->isVisible())
|
||||
<li class="fi-fo-repeater-add-between-items-ctn">
|
||||
<div class="fi-fo-repeater-add-between-items">
|
||||
{{ $addBetweenAction(['afterItem' => $itemKey]) }}
|
||||
</div>
|
||||
</li>
|
||||
@elseif (filled($labelBetweenItems))
|
||||
<li class="fi-fo-repeater-label-between-items-ctn">
|
||||
<div
|
||||
class="fi-fo-repeater-label-between-items-divider-before"
|
||||
></div>
|
||||
|
||||
<span
|
||||
class="fi-fo-repeater-label-between-items"
|
||||
>
|
||||
{{ $labelBetweenItems }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="fi-fo-repeater-label-between-items-divider-after"
|
||||
></div>
|
||||
</li>
|
||||
@endif
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@if ($isAddable && $addAction->isVisible())
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-repeater-add',
|
||||
($addActionAlignment instanceof Alignment) ? ('fi-align-' . $addActionAlignment->value) : $addActionAlignment,
|
||||
])
|
||||
>
|
||||
{{ $addAction }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,129 @@
|
||||
@php
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$items = $getItems();
|
||||
|
||||
$addAction = $getAction($getAddActionName());
|
||||
$addActionAlignment = $getAddActionAlignment();
|
||||
$cloneAction = $getAction($getCloneActionName());
|
||||
$deleteAction = $getAction($getDeleteActionName());
|
||||
$moveDownAction = $getAction($getMoveDownActionName());
|
||||
$moveUpAction = $getAction($getMoveUpActionName());
|
||||
$reorderAction = $getAction($getReorderActionName());
|
||||
$extraItemActions = $getExtraItemActions();
|
||||
|
||||
$isAddable = $isAddable();
|
||||
$isCloneable = $isCloneable();
|
||||
$isDeletable = $isDeletable();
|
||||
$isReorderableWithButtons = $isReorderableWithButtons();
|
||||
$isReorderableWithDragAndDrop = $isReorderableWithDragAndDrop();
|
||||
|
||||
$key = $getKey();
|
||||
$statePath = $getStatePath();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
{{
|
||||
$attributes
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->class(['fi-fo-simple-repeater'])
|
||||
}}
|
||||
>
|
||||
@if (count($items))
|
||||
<ul
|
||||
x-sortable
|
||||
{{
|
||||
(new ComponentAttributeBag)
|
||||
->grid($getGridColumns())
|
||||
->merge([
|
||||
'data-sortable-animation-duration' => $getReorderAnimationDuration(),
|
||||
'x-on:end.stop' => '$wire.mountAction(\'reorder\', { items: $event.target.sortable.toArray() }, { schemaComponent: \'' . $key . '\' })',
|
||||
], escape: false)
|
||||
->class(['fi-fo-simple-repeater-items'])
|
||||
}}
|
||||
>
|
||||
@foreach ($items as $itemKey => $item)
|
||||
@php
|
||||
$visibleExtraItemActions = array_filter(
|
||||
$extraItemActions,
|
||||
fn (Action $action): bool => $action(['item' => $itemKey])->isVisible(),
|
||||
);
|
||||
$cloneAction = $cloneAction(['item' => $itemKey]);
|
||||
$cloneActionIsVisible = $isCloneable && $cloneAction->isVisible();
|
||||
$deleteAction = $deleteAction(['item' => $itemKey]);
|
||||
$deleteActionIsVisible = $isDeletable && $deleteAction->isVisible();
|
||||
$moveDownAction = $moveDownAction(['item' => $itemKey])->disabled($loop->last);
|
||||
$moveDownActionIsVisible = $isReorderableWithButtons && $moveDownAction->isVisible();
|
||||
$moveUpAction = $moveUpAction(['item' => $itemKey])->disabled($loop->first);
|
||||
$moveUpActionIsVisible = $isReorderableWithButtons && $moveUpAction->isVisible();
|
||||
$reorderActionIsVisible = $isReorderableWithDragAndDrop && $reorderAction->isVisible();
|
||||
@endphp
|
||||
|
||||
<li
|
||||
wire:key="{{ $item->getLivewireKey() }}.item"
|
||||
x-sortable-item="{{ $itemKey }}"
|
||||
class="fi-fo-simple-repeater-item"
|
||||
>
|
||||
<div class="fi-fo-simple-repeater-item-content">
|
||||
{{ $item }}
|
||||
</div>
|
||||
|
||||
@if ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible || $cloneActionIsVisible || $deleteActionIsVisible || $visibleExtraItemActions)
|
||||
<ul class="fi-fo-simple-repeater-item-actions">
|
||||
@if ($reorderActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $reorderAction->extraAttributes(['x-sortable-handle' => true], merge: true) }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($moveUpActionIsVisible || $moveDownActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $moveUpAction }}
|
||||
</li>
|
||||
|
||||
<li x-on:click.stop>
|
||||
{{ $moveDownAction }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@foreach ($visibleExtraItemActions as $extraItemAction)
|
||||
<li x-on:click.stop>
|
||||
{{ $extraItemAction(['item' => $itemKey]) }}
|
||||
</li>
|
||||
@endforeach
|
||||
|
||||
@if ($cloneActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $cloneAction }}
|
||||
</li>
|
||||
@endif
|
||||
|
||||
@if ($deleteActionIsVisible)
|
||||
<li x-on:click.stop>
|
||||
{{ $deleteAction }}
|
||||
</li>
|
||||
@endif
|
||||
</ul>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
|
||||
@if ($isAddable && $addAction->isVisible())
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-simple-repeater-add',
|
||||
($addActionAlignment instanceof Alignment) ? ('fi-align-' . $addActionAlignment->value) : $addActionAlignment,
|
||||
])
|
||||
>
|
||||
{{ $addAction }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,225 @@
|
||||
@php
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Filament\Support\Enums\VerticalAlignment;
|
||||
use Illuminate\Support\Js;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
|
||||
$items = $getItems();
|
||||
|
||||
$addAction = $getAction($getAddActionName());
|
||||
$addActionAlignment = $getAddActionAlignment();
|
||||
$addBetweenAction = $getAction($getAddBetweenActionName());
|
||||
$cloneAction = $getAction($getCloneActionName());
|
||||
$deleteAction = $getAction($getDeleteActionName());
|
||||
$moveDownAction = $getAction($getMoveDownActionName());
|
||||
$moveUpAction = $getAction($getMoveUpActionName());
|
||||
$reorderAction = $getAction($getReorderActionName());
|
||||
$extraItemActions = $getExtraItemActions();
|
||||
|
||||
$isAddable = $isAddable();
|
||||
$isCloneable = $isCloneable();
|
||||
$isDeletable = $isDeletable();
|
||||
$isReorderableWithButtons = $isReorderableWithButtons();
|
||||
$isReorderableWithDragAndDrop = $isReorderableWithDragAndDrop();
|
||||
|
||||
$key = $getKey();
|
||||
$statePath = $getStatePath();
|
||||
|
||||
$tableColumns = $getTableColumns();
|
||||
|
||||
$isCompact = $isCompact();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
{{ $attributes
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->class([
|
||||
'fi-fo-table-repeater',
|
||||
'fi-compact' => $isCompact,
|
||||
]) }}
|
||||
>
|
||||
@if (count($items))
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@if ((count($items) > 1) && ($isReorderableWithButtons || $isReorderableWithDragAndDrop))
|
||||
<th
|
||||
class="fi-fo-table-repeater-empty-header-cell"
|
||||
></th>
|
||||
@endif
|
||||
|
||||
@foreach ($tableColumns as $column)
|
||||
<th
|
||||
@class([
|
||||
'fi-wrapped' => $column->canHeaderWrap(),
|
||||
(($columnAlignment = $column->getAlignment()) instanceof Alignment) ? ('fi-align-' . $columnAlignment->value) : $columnAlignment,
|
||||
])
|
||||
@style([
|
||||
('width: ' . ($columnWidth = $column->getWidth())) => filled($columnWidth),
|
||||
])
|
||||
>
|
||||
@if (! $column->isHeaderLabelHidden())
|
||||
{{ $column->getLabel() }}@if ($column->isMarkedAsRequired())<sup class="fi-fo-table-repeater-header-required-mark">*</sup>
|
||||
@endif
|
||||
@else
|
||||
<span class="fi-sr-only">
|
||||
{{ $column->getLabel() }}
|
||||
</span>
|
||||
@endif
|
||||
</th>
|
||||
@endforeach
|
||||
|
||||
@if (count($extraItemActions) || $isCloneable || $isDeletable)
|
||||
<th
|
||||
class="fi-fo-table-repeater-empty-header-cell"
|
||||
></th>
|
||||
@endif
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody
|
||||
x-sortable
|
||||
{{ (new ComponentAttributeBag)
|
||||
->merge([
|
||||
'data-sortable-animation-duration' => $getReorderAnimationDuration(),
|
||||
'x-on:end.stop' => '$wire.mountAction(\'reorder\', { items: $event.target.sortable.toArray() }, { schemaComponent: \'' . $key . '\' })',
|
||||
], escape: false) }}
|
||||
>
|
||||
@foreach ($items as $itemKey => $item)
|
||||
@php
|
||||
$visibleExtraItemActions = array_filter(
|
||||
$extraItemActions,
|
||||
fn (Action $action): bool => $action(['item' => $itemKey])->isVisible(),
|
||||
);
|
||||
$cloneAction = $cloneAction(['item' => $itemKey]);
|
||||
$cloneActionIsVisible = $isCloneable && $cloneAction->isVisible();
|
||||
$deleteAction = $deleteAction(['item' => $itemKey]);
|
||||
$deleteActionIsVisible = $isDeletable && $deleteAction->isVisible();
|
||||
$moveDownAction = $moveDownAction(['item' => $itemKey])->disabled($loop->last);
|
||||
$moveDownActionIsVisible = $isReorderableWithButtons && $moveDownAction->isVisible();
|
||||
$moveUpAction = $moveUpAction(['item' => $itemKey])->disabled($loop->first);
|
||||
$moveUpActionIsVisible = $isReorderableWithButtons && $moveUpAction->isVisible();
|
||||
$reorderActionIsVisible = $isReorderableWithDragAndDrop && $reorderAction->isVisible();
|
||||
$itemStatePath = $item->getStatePath();
|
||||
@endphp
|
||||
|
||||
<tr
|
||||
wire:key="{{ $item->getLivewireKey() }}.item"
|
||||
x-sortable-item="{{ $itemKey }}"
|
||||
>
|
||||
@if ((count($items) > 1) && ($isReorderableWithButtons || $isReorderableWithDragAndDrop))
|
||||
<td>
|
||||
@if ($reorderActionIsVisible || $moveUpActionIsVisible || $moveDownActionIsVisible)
|
||||
<div
|
||||
class="fi-fo-table-repeater-actions"
|
||||
>
|
||||
@if ($reorderActionIsVisible)
|
||||
<div x-on:click.stop>
|
||||
{{ $reorderAction->extraAttributes(['x-sortable-handle' => true], merge: true) }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($moveUpActionIsVisible || $moveDownActionIsVisible)
|
||||
<div x-on:click.stop>
|
||||
{{ $moveUpAction }}
|
||||
</div>
|
||||
|
||||
<div x-on:click.stop>
|
||||
{{ $moveDownAction }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$counter = 0
|
||||
@endphp
|
||||
|
||||
@foreach ($item->getComponents(withHidden: true) as $schemaComponent)
|
||||
@php
|
||||
throw_unless(
|
||||
$schemaComponent instanceof \Filament\Schemas\Components\Component,
|
||||
new \Exception('Table repeaters must only contain schema components, but [' . $schemaComponent::class . '] was used.'),
|
||||
);
|
||||
@endphp
|
||||
|
||||
@if (count($tableColumns) > $counter)
|
||||
@if ($schemaComponent instanceof \Filament\Forms\Components\Hidden)
|
||||
{{ $schemaComponent }}
|
||||
@else
|
||||
@php
|
||||
$counter++
|
||||
@endphp
|
||||
|
||||
@if ($schemaComponent->isVisible())
|
||||
@php
|
||||
$currentColumn = $tableColumns[$counter - 1] ?? null;
|
||||
$columnVerticalAlignment = $currentColumn?->getVerticalAlignment();
|
||||
@endphp
|
||||
|
||||
<td
|
||||
@class([
|
||||
($columnVerticalAlignment instanceof VerticalAlignment) ? ('fi-vertical-align-' . $columnVerticalAlignment->value) : (is_string($columnVerticalAlignment) ? $columnVerticalAlignment : ''),
|
||||
])
|
||||
>
|
||||
{!! $schemaComponent->toSchemaHtml() !!}
|
||||
</td>
|
||||
@else
|
||||
<td class="fi-hidden"></td>
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@if (count($extraItemActions) || $isCloneable || $isDeletable)
|
||||
<td>
|
||||
@if ($visibleExtraItemActions || $cloneActionIsVisible || $deleteActionIsVisible)
|
||||
<div
|
||||
class="fi-fo-table-repeater-actions"
|
||||
>
|
||||
@foreach ($visibleExtraItemActions as $extraItemAction)
|
||||
<div x-on:click.stop>
|
||||
{{ $extraItemAction(['item' => $itemKey]) }}
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if ($cloneActionIsVisible)
|
||||
<div x-on:click.stop>
|
||||
{{ $cloneAction }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($deleteActionIsVisible)
|
||||
<div x-on:click.stop>
|
||||
{{ $deleteAction }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
@if ($isAddable && $addAction->isVisible())
|
||||
<div
|
||||
@class([
|
||||
'fi-fo-table-repeater-add',
|
||||
($addActionAlignment instanceof Alignment) ? ('fi-align-' . $addActionAlignment->value) : $addActionAlignment,
|
||||
])
|
||||
>
|
||||
{{ $addAction }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,273 @@
|
||||
@php
|
||||
$customBlocks = $getCustomBlocks();
|
||||
$groupedCustomBlocks = $getGroupedCustomBlocks();
|
||||
$extraAttributeBag = $getExtraAttributeBag();
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$id = $getId();
|
||||
$isDisabled = $isDisabled();
|
||||
$label = $getLabel();
|
||||
$livewireKey = $getLivewireKey();
|
||||
$key = $getKey();
|
||||
$mergeTags = $getMergeTags();
|
||||
$statePath = $getStatePath();
|
||||
$mentions = $getMentionsForJs();
|
||||
$toolbarButtons = $getToolbarButtons();
|
||||
$tools = $getTools();
|
||||
$floatingToolbars = $getFloatingToolbars();
|
||||
$linkProtocols = $getLinkProtocols();
|
||||
$fileAttachmentsMaxSize = $getFileAttachmentsMaxSize();
|
||||
$fileAttachmentsAcceptedFileTypes = $getFileAttachmentsAcceptedFileTypes();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<x-filament::input.wrapper
|
||||
:disabled="$isDisabled"
|
||||
:valid="! $errors->has($statePath)"
|
||||
x-cloak
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes($extraAttributeBag)
|
||||
->class(['fi-fo-rich-editor'])
|
||||
"
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('rich-editor', 'filament/forms') }}"
|
||||
x-data="richEditorFormComponent({
|
||||
acceptedFileTypes: @js($fileAttachmentsAcceptedFileTypes),
|
||||
acceptedFileTypesValidationMessage: @js($fileAttachmentsAcceptedFileTypes ? __('filament-forms::components.rich_editor.file_attachments_accepted_file_types_message', ['values' => implode(', ', $fileAttachmentsAcceptedFileTypes)]) : null),
|
||||
activePanel: @js($getActivePanel()),
|
||||
canAttachFiles: @js($hasFileAttachments()),
|
||||
deleteCustomBlockButtonIconHtml: @js(\Filament\Support\generate_icon_html(\Filament\Support\Icons\Heroicon::Trash, alias: \Filament\Forms\View\FormsIconAlias::COMPONENTS_RICH_EDITOR_PANELS_CUSTOM_BLOCK_DELETE_BUTTON)->toHtml()),
|
||||
editCustomBlockButtonIconHtml: @js(\Filament\Support\generate_icon_html(\Filament\Support\Icons\Heroicon::PencilSquare, alias: \Filament\Forms\View\FormsIconAlias::COMPONENTS_RICH_EDITOR_PANELS_CUSTOM_BLOCK_EDIT_BUTTON)->toHtml()),
|
||||
extensions: @js($getTipTapJsExtensions()),
|
||||
floatingToolbars: @js($floatingToolbars),
|
||||
getMentionLabelsUsing: async (mentions) => {
|
||||
return await $wire.callSchemaComponentMethod(
|
||||
@js($key),
|
||||
'getMentionLabelsForJs',
|
||||
{ mentions },
|
||||
)
|
||||
},
|
||||
getMentionSearchResultsUsing: async (query, char) => {
|
||||
return await $wire.callSchemaComponentMethod(
|
||||
@js($key),
|
||||
'getMentionSearchResultsForJs',
|
||||
{ search: query, char },
|
||||
)
|
||||
},
|
||||
hasResizableImages: @js($hasResizableImages()),
|
||||
isDisabled: @js($isDisabled),
|
||||
label: @js($label),
|
||||
isLiveDebounced: @js($isLiveDebounced()),
|
||||
isLiveOnBlur: @js($isLiveOnBlur()),
|
||||
key: @js($key),
|
||||
linkProtocols: @js($linkProtocols),
|
||||
liveDebounce: @js($getNormalizedLiveDebounce()),
|
||||
livewireId: @js($this->getId()),
|
||||
maxFileSize: @js($fileAttachmentsMaxSize),
|
||||
maxFileSizeValidationMessage: @js($fileAttachmentsMaxSize ? trans_choice('filament-forms::components.rich_editor.file_attachments_max_size_message', $fileAttachmentsMaxSize, ['max' => $fileAttachmentsMaxSize]) : null),
|
||||
mentions: @js($mentions),
|
||||
mergeTags: @js($mergeTags),
|
||||
noMergeTagSearchResultsMessage: @js($getNoMergeTagSearchResultsMessage()),
|
||||
placeholder: @js($getPlaceholder()),
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')", isOptimisticallyLive: false) }},
|
||||
statePath: @js($statePath),
|
||||
textColors: @js($getTextColorsForJs()),
|
||||
uploadingFileMessage: @js($getUploadingFileMessage()),
|
||||
})"
|
||||
x-bind:class="{
|
||||
'fi-fo-rich-editor-uploading-file': isUploadingFile,
|
||||
}"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.{{
|
||||
substr(md5(serialize([
|
||||
$isDisabled,
|
||||
])), 0, 64)
|
||||
}}"
|
||||
>
|
||||
@if ((! $isDisabled) && filled($toolbarButtons))
|
||||
<div class="fi-fo-rich-editor-toolbar">
|
||||
@foreach ($toolbarButtons as $button => $buttonGroup)
|
||||
<div class="fi-fo-rich-editor-toolbar-group">
|
||||
@foreach ($buttonGroup as $button)
|
||||
@if (is_string($button))
|
||||
{{ $tools[$button] ?? throw new LogicException("Toolbar button [{$button}] cannot be found.") }}
|
||||
@else
|
||||
{{ $button }}
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div
|
||||
x-show="isUploadingFile"
|
||||
x-cloak
|
||||
class="fi-fo-rich-editor-uploading-file-message"
|
||||
>
|
||||
{{ \Filament\Support\generate_loading_indicator_html() }}
|
||||
|
||||
<span>
|
||||
{{ $getUploadingFileMessage() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="! isUploadingFile && fileValidationMessage"
|
||||
x-cloak
|
||||
class="fi-fo-rich-editor-file-validation-message"
|
||||
>
|
||||
<span
|
||||
x-text="fileValidationMessage"
|
||||
x-show="! isUploadingFile && fileValidationMessage"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
{{ $getExtraInputAttributeBag()->class(['fi-fo-rich-editor-main']) }}
|
||||
>
|
||||
<div class="fi-fo-rich-editor-content fi-prose" x-ref="editor">
|
||||
@foreach ($floatingToolbars as $nodeName => $buttons)
|
||||
<div
|
||||
x-ref="floatingToolbar::{{ $nodeName }}"
|
||||
class="fi-fo-rich-editor-floating-toolbar fi-not-prose"
|
||||
>
|
||||
@foreach ($buttons as $button)
|
||||
@if (is_string($button))
|
||||
{{ $tools[$button] }}
|
||||
@else
|
||||
{{ $button }}
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (! $isDisabled)
|
||||
<div
|
||||
x-show="isPanelActive()"
|
||||
x-cloak
|
||||
class="fi-fo-rich-editor-panels"
|
||||
>
|
||||
<div
|
||||
x-show="isPanelActive('customBlocks')"
|
||||
x-cloak
|
||||
class="fi-fo-rich-editor-panel"
|
||||
>
|
||||
<div class="fi-fo-rich-editor-panel-header">
|
||||
<p class="fi-fo-rich-editor-panel-heading">
|
||||
{{ __('filament-forms::components.rich_editor.tools.custom_blocks') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="fi-fo-rich-editor-panel-close-btn-ctn"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="togglePanel()"
|
||||
class="fi-icon-btn"
|
||||
>
|
||||
{{ \Filament\Support\generate_icon_html(\Filament\Support\Icons\Heroicon::XMark, alias: \Filament\Forms\View\FormsIconAlias::COMPONENTS_RICH_EDITOR_PANELS_CUSTOM_BLOCKS_CLOSE_BUTTON) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fi-fo-rich-editor-custom-blocks-ctn">
|
||||
@foreach ($groupedCustomBlocks as $customBlockGroupLabel => $groupBlocks)
|
||||
@if (filled($customBlockGroupLabel))
|
||||
<h4
|
||||
class="fi-fo-rich-editor-custom-blocks-group-header"
|
||||
>
|
||||
{{ $customBlockGroupLabel }}
|
||||
</h4>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="fi-fo-rich-editor-custom-blocks-list"
|
||||
>
|
||||
@foreach ($groupBlocks as $block)
|
||||
@php
|
||||
$blockId = $block::getId();
|
||||
@endphp
|
||||
|
||||
<button
|
||||
draggable="true"
|
||||
type="button"
|
||||
x-data="{ isLoading: false }"
|
||||
x-on:click="
|
||||
isLoading = true
|
||||
|
||||
$wire.mountAction(
|
||||
'customBlock',
|
||||
{ editorSelection, id: @js($blockId), mode: 'insert' },
|
||||
{ schemaComponent: @js($key) },
|
||||
)
|
||||
"
|
||||
x-on:dragstart="$event.dataTransfer.setData('customBlock', @js($blockId))"
|
||||
x-on:open-modal.window="isLoading = false"
|
||||
x-on:run-rich-editor-commands.window="isLoading = false"
|
||||
class="fi-fo-rich-editor-custom-block-btn"
|
||||
>
|
||||
{{
|
||||
\Filament\Support\generate_loading_indicator_html((new \Illuminate\View\ComponentAttributeBag([
|
||||
'x-show' => 'isLoading',
|
||||
])))
|
||||
}}
|
||||
|
||||
{{ $block::getLabel() }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="isPanelActive('mergeTags')"
|
||||
x-cloak
|
||||
class="fi-fo-rich-editor-panel"
|
||||
>
|
||||
<div class="fi-fo-rich-editor-panel-header">
|
||||
<p class="fi-fo-rich-editor-panel-heading">
|
||||
{{ __('filament-forms::components.rich_editor.tools.merge_tags') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="fi-fo-rich-editor-panel-close-btn-ctn"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="togglePanel()"
|
||||
class="fi-icon-btn"
|
||||
>
|
||||
{{ \Filament\Support\generate_icon_html(\Filament\Support\Icons\Heroicon::XMark, alias: \Filament\Forms\View\FormsIconAlias::COMPONENTS_RICH_EDITOR_PANELS_MERGE_TAGS_CLOSE_BUTTON) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fi-fo-rich-editor-merge-tags-list">
|
||||
@foreach ($mergeTags as $tagId => $tagLabel)
|
||||
<button
|
||||
draggable="true"
|
||||
type="button"
|
||||
x-on:click="insertMergeTag(@js($tagId))"
|
||||
x-on:dragstart="$event.dataTransfer.setData('mergeTag', @js($tagId))"
|
||||
class="fi-fo-rich-editor-merge-tag-btn"
|
||||
>
|
||||
<span
|
||||
data-type="mergeTag"
|
||||
data-id="{{ $tagId }}"
|
||||
>
|
||||
{{ $tagLabel }}
|
||||
</span>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::input.wrapper>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,217 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraInputAttributeBag = $getExtraInputAttributeBag();
|
||||
$canSelectPlaceholder = $canSelectPlaceholder();
|
||||
$isAutofocused = $isAutofocused();
|
||||
$isDisabled = $isDisabled();
|
||||
$isMultiple = $isMultiple();
|
||||
$isReorderable = $isReorderable();
|
||||
$isSearchable = $isSearchable();
|
||||
$hasInitialNoOptionsMessage = $hasInitialNoOptionsMessage();
|
||||
$canOptionLabelsWrap = $canOptionLabelsWrap();
|
||||
$isRequired = $isRequired();
|
||||
$isConcealed = $isConcealed();
|
||||
$isHtmlAllowed = $isHtmlAllowed();
|
||||
$isNative = (! ($isSearchable || $isMultiple || $isHtmlAllowed) && $isNative());
|
||||
$isPrefixInline = $isPrefixInline();
|
||||
$isSuffixInline = $isSuffixInline();
|
||||
$key = $getKey();
|
||||
$id = $getId();
|
||||
$prefixActions = $getPrefixActions();
|
||||
$prefixIcon = $getPrefixIcon();
|
||||
$prefixIconColor = $getPrefixIconColor();
|
||||
$prefixLabel = $getPrefixLabel();
|
||||
$suffixActions = $getSuffixActions();
|
||||
$suffixIcon = $getSuffixIcon();
|
||||
$suffixIconColor = $getSuffixIconColor();
|
||||
$suffixLabel = $getSuffixLabel();
|
||||
$statePath = $getStatePath();
|
||||
$state = $getRawState();
|
||||
$livewireKey = $getLivewireKey();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
class="fi-fo-select-wrp"
|
||||
>
|
||||
<x-filament::input.wrapper
|
||||
:disabled="$isDisabled"
|
||||
:inline-prefix="$isPrefixInline"
|
||||
:inline-suffix="$isSuffixInline"
|
||||
:prefix="$prefixLabel"
|
||||
:prefix-actions="$prefixActions"
|
||||
:prefix-icon="$prefixIcon"
|
||||
:prefix-icon-color="$prefixIconColor"
|
||||
:suffix="$suffixLabel"
|
||||
:suffix-actions="$suffixActions"
|
||||
:suffix-icon="$suffixIcon"
|
||||
:suffix-icon-color="$suffixIconColor"
|
||||
:valid="! $errors->has($statePath)"
|
||||
:x-on:focus-input.stop="$isNative ? '$el.querySelector(\'select\')?.focus()' : '$el.querySelector(\'.fi-select-input-btn\')?.focus()'"
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes($getExtraAttributeBag())
|
||||
->class([
|
||||
'fi-fo-select',
|
||||
'fi-fo-select-has-inline-prefix' => $isPrefixInline && (count($prefixActions) || $prefixIcon || filled($prefixLabel)),
|
||||
'fi-fo-select-native' => $isNative,
|
||||
])
|
||||
"
|
||||
>
|
||||
@if ($isNative)
|
||||
<select
|
||||
{{
|
||||
$extraInputAttributeBag
|
||||
->merge([
|
||||
'autofocus' => $isAutofocused,
|
||||
'disabled' => $isDisabled,
|
||||
'id' => $id,
|
||||
'required' => $isRequired && (! $isConcealed),
|
||||
$applyStateBindingModifiers('wire:model') => $statePath,
|
||||
], escape: false)
|
||||
->class([
|
||||
'fi-select-input',
|
||||
'fi-select-input-has-inline-prefix' => $isPrefixInline && (count($prefixActions) || $prefixIcon || filled($prefixLabel)),
|
||||
])
|
||||
}}
|
||||
>
|
||||
@if ($canSelectPlaceholder)
|
||||
<option value="">
|
||||
@if (! $isDisabled)
|
||||
{{ $getPlaceholder() }}
|
||||
@endif
|
||||
</option>
|
||||
@endif
|
||||
|
||||
@foreach ($getOptions() as $value => $label)
|
||||
@if (is_array($label))
|
||||
<optgroup label="{{ $value }}">
|
||||
@foreach ($label as $groupedValue => $groupedLabel)
|
||||
<option
|
||||
@disabled($isOptionDisabled($groupedValue, $groupedLabel))
|
||||
value="{{ $groupedValue }}"
|
||||
>
|
||||
@if ($isHtmlAllowed)
|
||||
{!! $groupedLabel !!}
|
||||
@else
|
||||
{{ $groupedLabel }}
|
||||
@endif
|
||||
</option>
|
||||
@endforeach
|
||||
</optgroup>
|
||||
@else
|
||||
<option
|
||||
@disabled($isOptionDisabled($value, $label))
|
||||
value="{{ $value }}"
|
||||
>
|
||||
@if ($isHtmlAllowed)
|
||||
{!! $label !!}
|
||||
@else
|
||||
{{ $label }}
|
||||
@endif
|
||||
</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
@else
|
||||
<div
|
||||
class="fi-hidden"
|
||||
x-data="{
|
||||
isDisabled: @js($isDisabled),
|
||||
init() {
|
||||
const container = $el.nextElementSibling
|
||||
container.dispatchEvent(
|
||||
new CustomEvent('set-select-property', {
|
||||
detail: { isDisabled: this.isDisabled },
|
||||
}),
|
||||
)
|
||||
},
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('select', 'filament/forms') }}"
|
||||
x-data="selectFormComponent({
|
||||
canOptionLabelsWrap: @js($canOptionLabelsWrap),
|
||||
canSelectPlaceholder: @js($canSelectPlaceholder),
|
||||
getOptionLabelUsing: async () => {
|
||||
return await Livewire.fireAction(
|
||||
$wire.__instance,
|
||||
'callSchemaComponentMethod',
|
||||
[@js($key), 'getOptionLabel'],
|
||||
{ async: true },
|
||||
)
|
||||
},
|
||||
getOptionLabelsUsing: async () => {
|
||||
return await Livewire.fireAction(
|
||||
$wire.__instance,
|
||||
'callSchemaComponentMethod',
|
||||
[@js($key), 'getOptionLabelsForJs'],
|
||||
{ async: true },
|
||||
)
|
||||
},
|
||||
getOptionsUsing: async () => {
|
||||
return await Livewire.fireAction(
|
||||
$wire.__instance,
|
||||
'callSchemaComponentMethod',
|
||||
[@js($key), 'getOptionsForJs'],
|
||||
{ async: true },
|
||||
)
|
||||
},
|
||||
getSearchResultsUsing: async (search) => {
|
||||
return await Livewire.fireAction(
|
||||
$wire.__instance,
|
||||
'callSchemaComponentMethod',
|
||||
[@js($key), 'getSearchResultsForJs', { search }],
|
||||
{ async: true },
|
||||
)
|
||||
},
|
||||
hasDynamicOptions: @js($hasDynamicOptions()),
|
||||
hasDynamicSearchResults: @js($hasDynamicSearchResults()),
|
||||
hasInitialNoOptionsMessage: @js($hasInitialNoOptionsMessage),
|
||||
initialOptionLabel: @js((blank($state) || $isMultiple) ? null : $getOptionLabel()),
|
||||
initialOptionLabels: @js((filled($state) && $isMultiple) ? $getOptionLabelsForJs() : []),
|
||||
initialState: @js($state),
|
||||
isAutofocused: @js($isAutofocused),
|
||||
isDisabled: @js($isDisabled),
|
||||
isHtmlAllowed: @js($isHtmlAllowed),
|
||||
isMultiple: @js($isMultiple),
|
||||
isReorderable: @js($isReorderable),
|
||||
isSearchable: @js($isSearchable),
|
||||
livewireId: @js($this->getId()),
|
||||
loadingMessage: @js($getLoadingMessage()),
|
||||
maxItems: @js($getMaxItems()),
|
||||
maxItemsMessage: @js($getMaxItemsMessage()),
|
||||
noOptionsMessage: @js($getNoOptionsMessage()),
|
||||
noSearchResultsMessage: @js($getNoSearchResultsMessage()),
|
||||
options: @js($getOptionsForJs()),
|
||||
optionsLimit: @js($getOptionsLimit()),
|
||||
placeholder: @js($getPlaceholder()),
|
||||
position: @js($getPosition()),
|
||||
searchDebounce: @js($getSearchDebounce()),
|
||||
searchingMessage: @js($getSearchingMessage()),
|
||||
searchPrompt: @js($getSearchPrompt()),
|
||||
searchableOptionFields: @js($getSearchableOptionFields()),
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
|
||||
statePath: @js($statePath),
|
||||
})"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.{{
|
||||
substr(md5(serialize([
|
||||
$isDisabled,
|
||||
$isReorderable,
|
||||
])), 0, 64)
|
||||
}}"
|
||||
x-on:keydown.esc="select.dropdown.isActive && $event.stopPropagation()"
|
||||
x-on:set-select-property="$event.detail.isDisabled ? select.disable() : select.enable()"
|
||||
{{
|
||||
$attributes
|
||||
->merge($getExtraAlpineAttributes(), escape: false)
|
||||
->class(['fi-select-input'])
|
||||
}}
|
||||
>
|
||||
<div x-ref="select"></div>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::input.wrapper>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,61 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$isVertical = $isVertical();
|
||||
$pipsMode = $getPipsMode();
|
||||
$livewireKey = $getLivewireKey();
|
||||
$isDisabled = $isDisabled();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
:inline-label-vertical-alignment="\Filament\Support\Enums\VerticalAlignment::Center"
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('slider', 'filament/forms') }}"
|
||||
x-data="sliderFormComponent({
|
||||
arePipsStepped: @js($arePipsStepped()),
|
||||
behavior: @js($getBehaviorForJs()),
|
||||
decimalPlaces: @js($getDecimalPlaces()),
|
||||
fillTrack: @js($getFillTrack()),
|
||||
isDisabled: @js($isDisabled),
|
||||
isRtl: @js($isRtl()),
|
||||
isVertical: @js($isVertical),
|
||||
maxDifference: @js($getMaxDifference()),
|
||||
minDifference: @js($getMinDifference()),
|
||||
maxValue: @js($getMaxValue()),
|
||||
minValue: @js($getMinValue()),
|
||||
nonLinearPoints: @js($getNonLinearPoints()),
|
||||
pipsDensity: @js($getPipsDensity()),
|
||||
pipsFilter: @js($getPipsFilterForJs()),
|
||||
pipsFormatter: @js($getPipsFormatterForJs()),
|
||||
pipsMode: @js($pipsMode),
|
||||
pipsValues: @js($getPipsValues()),
|
||||
rangePadding: @js($getRangePadding()),
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }},
|
||||
step: @js($getStep()),
|
||||
tooltips: @js($getTooltipsForJs()),
|
||||
})"
|
||||
wire:ignore
|
||||
wire:key="{{ $livewireKey }}.{{
|
||||
substr(md5(serialize([
|
||||
$isDisabled,
|
||||
])), 0, 64)
|
||||
}}"
|
||||
{{
|
||||
$attributes
|
||||
->merge([
|
||||
'id' => $getId(),
|
||||
], escape: false)
|
||||
->merge($getExtraAttributes(), escape: false)
|
||||
->merge($getExtraAlpineAttributes(), escape: false)
|
||||
->class([
|
||||
'fi-fo-slider',
|
||||
'fi-fo-slider-has-pips' => $pipsMode,
|
||||
'fi-fo-slider-has-tooltips' => $hasTooltips(),
|
||||
'fi-fo-slider-vertical' => $isVertical,
|
||||
])
|
||||
}}
|
||||
></div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,31 @@
|
||||
@php
|
||||
use Filament\Forms\Components\TableSelect\Livewire\TableSelectLivewireComponent;
|
||||
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributes = $getExtraAttributes();
|
||||
$id = $getId();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
|
||||
<div
|
||||
{{
|
||||
$attributes
|
||||
->merge([
|
||||
'id' => $id,
|
||||
], escape: false)
|
||||
->merge($extraAttributes, escape: false)
|
||||
}}
|
||||
>
|
||||
@livewire(TableSelectLivewireComponent::class, [
|
||||
'isDisabled' => $isDisabled(),
|
||||
'maxSelectableRecords' => $getMaxItems(),
|
||||
'model' => $getModel(),
|
||||
'record' => $getRecord(),
|
||||
'relationshipName' => $getRelationshipName(),
|
||||
'shouldIgnoreRelatedRecords' => $shouldIgnoreRelatedRecords(),
|
||||
'tableConfiguration' => base64_encode($getTableConfiguration()),
|
||||
'tableArguments' => $getTableArguments(),
|
||||
$applyStateBindingModifiers('wire:model') => $getStatePath(),
|
||||
], key($getLivewireKey()))
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
@@ -0,0 +1,133 @@
|
||||
@php
|
||||
$fieldWrapperView = $getFieldWrapperView();
|
||||
$extraAttributes = $getExtraAttributes();
|
||||
$extraInputAttributeBag = $getExtraInputAttributeBag();
|
||||
$color = $getColor() ?? 'primary';
|
||||
$id = $getId();
|
||||
$isAutofocused = $isAutofocused();
|
||||
$isDisabled = $isDisabled();
|
||||
$isPrefixInline = $isPrefixInline();
|
||||
$isReorderable = (! $isDisabled) && $isReorderable();
|
||||
$isSuffixInline = $isSuffixInline();
|
||||
$placeholder = $getPlaceholder();
|
||||
$prefixActions = $getPrefixActions();
|
||||
$prefixIcon = $getPrefixIcon();
|
||||
$prefixIconColor = $getPrefixIconColor();
|
||||
$prefixLabel = $getPrefixLabel();
|
||||
$statePath = $getStatePath();
|
||||
$suffixActions = $getSuffixActions();
|
||||
$suffixIcon = $getSuffixIcon();
|
||||
$suffixIconColor = $getSuffixIconColor();
|
||||
$suffixLabel = $getSuffixLabel();
|
||||
@endphp
|
||||
|
||||
<x-dynamic-component
|
||||
:component="$fieldWrapperView"
|
||||
:field="$field"
|
||||
class="fi-fo-tags-input-wrp"
|
||||
>
|
||||
<x-filament::input.wrapper
|
||||
:disabled="$isDisabled"
|
||||
:inline-prefix="$isPrefixInline"
|
||||
:inline-suffix="$isSuffixInline"
|
||||
:prefix="$prefixLabel"
|
||||
:prefix-actions="$prefixActions"
|
||||
:prefix-icon="$prefixIcon"
|
||||
:prefix-icon-color="$prefixIconColor"
|
||||
:suffix="$suffixLabel"
|
||||
:suffix-actions="$suffixActions"
|
||||
:suffix-icon="$suffixIcon"
|
||||
:suffix-icon-color="$suffixIconColor"
|
||||
:valid="! $errors->has($statePath)"
|
||||
x-on:focus-input.stop="$el.querySelector('input')?.focus()"
|
||||
:attributes="
|
||||
\Filament\Support\prepare_inherited_attributes($attributes)
|
||||
->merge($extraAttributes, escape: false)
|
||||
->class([
|
||||
'fi-fo-tags-input',
|
||||
'fi-disabled' => $isDisabled,
|
||||
])
|
||||
"
|
||||
>
|
||||
<div
|
||||
x-load
|
||||
x-load-src="{{ \Filament\Support\Facades\FilamentAsset::getAlpineComponentSrc('tags-input', 'filament/forms') }}"
|
||||
x-data="tagsInputFormComponent({
|
||||
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
|
||||
splitKeys: @js($getSplitKeys()),
|
||||
})"
|
||||
{{ $getExtraAlpineAttributeBag() }}
|
||||
>
|
||||
<input
|
||||
{{
|
||||
$extraInputAttributeBag
|
||||
->merge([
|
||||
'autocomplete' => 'off',
|
||||
'autofocus' => $isAutofocused,
|
||||
'disabled' => $isDisabled,
|
||||
'id' => $id,
|
||||
'list' => $id . '-suggestions',
|
||||
'placeholder' => filled($placeholder) ? e($placeholder) : null,
|
||||
'type' => 'text',
|
||||
'x-bind' => 'input',
|
||||
], escape: false)
|
||||
->class([
|
||||
'fi-input',
|
||||
'fi-input-has-inline-prefix' => $isPrefixInline && (count($prefixActions) || $prefixIcon || filled($prefixLabel)),
|
||||
'fi-input-has-inline-suffix' => $isSuffixInline && (count($suffixActions) || $suffixIcon || filled($suffixLabel)),
|
||||
])
|
||||
}}
|
||||
/>
|
||||
|
||||
<datalist id="{{ $id }}-suggestions">
|
||||
@foreach ($getSuggestions() as $suggestion)
|
||||
<template
|
||||
x-bind:key="@js($suggestion)"
|
||||
x-if="! (state?.includes(@js($suggestion)) ?? true)"
|
||||
>
|
||||
<option value="{{ $suggestion }}" />
|
||||
</template>
|
||||
@endforeach
|
||||
</datalist>
|
||||
|
||||
<div wire:ignore>
|
||||
<template x-cloak x-if="state?.length">
|
||||
<div
|
||||
@if ($isReorderable)
|
||||
x-on:end.stop="reorderTags($event)"
|
||||
x-sortable
|
||||
data-sortable-animation-duration="{{ $getReorderAnimationDuration() }}"
|
||||
@endif
|
||||
class="fi-fo-tags-input-tags-ctn"
|
||||
>
|
||||
<template
|
||||
x-for="(tag, index) in state"
|
||||
x-bind:key="`${tag}-${index}`"
|
||||
>
|
||||
<x-filament::badge
|
||||
:color="$color"
|
||||
:x-bind:x-sortable-item="$isReorderable ? 'index' : null"
|
||||
:x-sortable-handle="$isReorderable ? '' : null"
|
||||
@class([
|
||||
'fi-reorderable' => $isReorderable,
|
||||
])
|
||||
>
|
||||
{{ $getTagPrefix() }}
|
||||
|
||||
<span x-text="tag"></span>
|
||||
|
||||
{{ $getTagSuffix() }}
|
||||
|
||||
<x-slot
|
||||
name="deleteButton"
|
||||
x-on:click.stop="deleteTag(tag)"
|
||||
:x-bind:aria-label="'\'' . __('filament-forms::components.tags_input.actions.delete.label') . ': \' + tag'"
|
||||
></x-slot>
|
||||
</x-filament::badge>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::input.wrapper>
|
||||
</x-dynamic-component>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user