This commit is contained in:
User
2024-12-27 20:43:18 -05:00
parent 89d0394de9
commit da918835c2
84 changed files with 6160 additions and 1068 deletions

17
app/Enum/MimeType.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enum;
enum MimeType: string
{
case png = 'image/png';
case jpg = 'image/jpeg';
case gif = 'image/gif';
case bmp = 'image/bmp';
case ico = 'image/vnd.microsoft.icon';
case tiff = 'image/tiff';
case svg = 'image/svg+xml';
case webp = 'image/webp';
}

View File

@@ -30,7 +30,7 @@ class NewPasswordController extends Controller
/** /**
* Handle an incoming new password request. * Handle an incoming new password request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

View File

@@ -25,7 +25,7 @@ class PasswordResetLinkController extends Controller
/** /**
* Handle an incoming password reset link request. * Handle an incoming password reset link request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {

View File

@@ -10,8 +10,10 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules; use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use function Laravel\Prompts\error;
class RegisteredUserController extends Controller class RegisteredUserController extends Controller
{ {
@@ -26,13 +28,13 @@ class RegisteredUserController extends Controller
/** /**
* Handle an incoming registration request. * Handle an incoming registration request.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, 'email' => 'required|string|lowercase|email|max:255|contains:yumj.in|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);

View File

@@ -0,0 +1,230 @@
<?php
namespace App\Http\Controllers;
use App\Models\Author;
use App\Models\Chapter;
use App\Models\Comic;
use App\Models\Image;
use App\Remote\CopyManga;
use App\Remote\ImageFetcher;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response as IlluminateHttpResponse;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
class ComicController extends Controller
{
public function __construct(private readonly CopyManga $copyManga)
{
}
public function favourites(Request $request)
{
$favourites = $request->user()->favourites()->with(['authors'])->orderBy('upstream_updated_at', 'desc')->get();
return Inertia::render('Comic/Favourites', [
'favourites' => $favourites,
]);
}
public function postFavourite(Request $request): JsonResponse
{
try {
// Get pathname to comic_id
$comic = Comic::where('pathword', $request->pathword)->firstOrFail();
} catch (ModelNotFoundException $e) {
// Fetch from remote
$remoteComic = $this->copyManga->comic($request->pathword);
$comic = new Comic;
$comic->pathword = $remoteComic['comic']['path_word'];
$comic->name = $remoteComic['comic']['name'];
$comic->cover = $remoteComic['comic']['cover'];
$comic->upstream_updated_at = $remoteComic['comic']['datetime_updated'];
$comic->uuid = $remoteComic['comic']['uuid'];
$comic->alias = explode(',', $remoteComic['comic']['alias']);
$comic->description = $remoteComic['comic']['brief'];
$comic->metadata = $remoteComic;
$comic->save();
}
// Set favourite
if ($request->user()->favourites()->where('comic_id', $comic->id)->exists()) {
$request->user()->favourites()->detach($comic->id);
} else {
$request->user()->favourites()->attach($comic->id);
}
return response()->json($request->user()->favourites()->get(['pathword'])->pluck('pathword'));
}
public function image(Request $request, string $url): ResponseFactory|Application|IlluminateHttpResponse
{
// TODO: Ref check and make it require auth
$fetcher = new ImageFetcher(base64_decode($url));
return response($fetcher->fetch())->withHeaders([
'Content-Type' => $fetcher->getMimeType()->value,
'Cache-Control' => 'max-age=604800',
]);
}
public function index(Request $request): Response
{
$params = [];
if ($request->has('tag')) {
$params['theme'] = $request->get('tag');
}
$comics = $this->copyManga->comics(30, $request->header('offset', 0), $request->get('top', 'all'), $params);
// Prep the array for upsert
$comicsUpsertArray = [];
$authorsUpsertArray = [];
foreach ($comics['list'] as $comic) {
$comicsUpsertArray[] = [
'pathword' => $comic['path_word'],
'uuid' => '',
'name' => $comic['name'],
'alias' => '{}',
'description' => '',
'cover' => $comic['cover'],
'upstream_updated_at' => $comic['datetime_updated'],
];
foreach ($comic['author'] as $author) {
$authorsUpsertArray[] = [
'name' => $author['name']
];
}
}
// Do an upsert for comics
Comic::upsert($comicsUpsertArray, uniqueBy: 'pathword', update: ['upstream_updated_at']);
Author::upsert($authorsUpsertArray, uniqueBy: 'name');
// Had to do a second pass to insert the relationships
foreach ($comics['list'] as $comic) {
// Get the comic id
$comicObj = Comic::where('pathword', $comic['path_word'])->first();
foreach ($comic['author'] as $author) {
$authorObj = Author::where('name', $author['name'])->first();
$comicObj->authors()->sync($authorObj);
}
}
return Inertia::render('Comic/Index', [
'comics' => $comics,
'offset' => $request->header('offset', 0)
]);
}
public function chapters(Request $request, string $pathword = ''): Response
{
$comic = $this->copyManga->comic($pathword);
$chapters = $this->copyManga->chapters($pathword, 200, 0, [], $request->get('group', 'default'));
// Get the comic object and fill other parameters
$comicObject = Comic::where('pathword', $pathword)->first();
$comicObject->uuid = $comic['comic']['uuid'];
$comicObject->alias = explode(',', $comic['comic']['alias']);
$comicObject->description = $comic['comic']['brief'];
$comicObject->metadata = $comic;
$comicObject->save();
// Get the authors and update the pathword
foreach ($comic['comic']['author'] as $author) {
$authorObj = Author::where('name', $author['name'])->whereNull('pathword')->first();
if ($authorObj) {
// Do nothing if pathword already exist
$authorObj->pathword = $author['path_word'];
$authorObj->save();
}
}
// Do the Chapters
// Prep the array for upsert
$arrayForUpsert = [];
foreach ($chapters['list'] as $chapter) {
$arrayForUpsert[] = [
'comic_id' => $comicObject->id,
'chapter_uuid' => $chapter['uuid'],
'name' => $chapter['name'],
'order' => $chapter['index'],
'upstream_created_at' => $chapter['datetime_created'],
'metadata' => json_encode($chapter),
];
}
// Do an upsert
Chapter::upsert($arrayForUpsert, uniqueBy: 'chapter_uuid');
return Inertia::render('Comic/Chapters', [
'comic' => $comic,
'chapters' => $chapters,
]);
}
public function read(Request $request, string $pathword = '', string $uuid = ''): Response
{
$comic = $this->copyManga->comic($pathword);
$chapter = $this->copyManga->chapter($pathword, $uuid);
// Get the authors and update the pathword
foreach ($comic['comic']['author'] as $author) {
$authorObj = Author::where('name', $author['name'])->whereNull('pathword')->first();
if ($authorObj) {
// Do nothing if pathword already exist
$authorObj->pathword = $author['path_word'];
$authorObj->save();
}
}
$chapterObj = Chapter::where('chapter_uuid', $chapter['chapter']['uuid'])->first();
$comicObj = Comic::where('pathword', $pathword)->first();
$arrayForUpsert = [];
foreach ($chapter['sorted'] as $k => $image) {
$metadata = $chapter;
unset($metadata['sorted']);
unset($metadata['chapter']['contents'], $metadata['chapter']['words']);
$arrayForUpsert[] = [
'comic_id' => $comicObj->id,
'chapter_id' => $chapterObj->id,
'order' => $k,
'url' => $image['url'],
'metadata' => json_encode($metadata),
];
}
// Do an upsert
Image::upsert($arrayForUpsert, uniqueBy: 'url');
return Inertia::render('Comic/Read', [
'comic' => $comic,
'chapter' => $chapter,
]);
}
public function tags()
{
// TODO
$tags = $this->copyManga->tags();
Cache::forever('tags', $tags);
return response()->json($tags);
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Http\Resources\UserCollection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Middleware; use Inertia\Middleware;
class HandleInertiaRequests extends Middleware class HandleInertiaRequests extends Middleware
@@ -31,8 +33,9 @@ class HandleInertiaRequests extends Middleware
{ {
return [ return [
...parent::share($request), ...parent::share($request),
'tags' => Cache::get('tags'),
'auth' => [ 'auth' => [
'user' => $request->user(), 'user' => $request->user() ? (new UserCollection($request->user()))->toArray($request) : null,
], ],
]; ];
} }

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests\Auth; namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout; use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -22,7 +23,7 @@ class LoginRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string> * @return array<string, ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -35,7 +36,7 @@ class LoginRequest extends FormRequest
/** /**
* Attempt to authenticate the request's credentials. * Attempt to authenticate the request's credentials.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function authenticate(): void public function authenticate(): void
{ {
@@ -55,7 +56,7 @@ class LoginRequest extends FormRequest
/** /**
* Ensure the login request is not rate limited. * Ensure the login request is not rate limited.
* *
* @throws \Illuminate\Validation\ValidationException * @throws ValidationException
*/ */
public function ensureIsNotRateLimited(): void public function ensureIsNotRateLimited(): void
{ {

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -11,7 +12,7 @@ class ProfileUpdateRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string> * @return array<string, ValidationRule|array<mixed>|string>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -19,6 +20,7 @@ class ProfileUpdateRequest extends FormRequest
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => [ 'email' => [
'required', 'required',
'contains:@yumj.in',
'string', 'string',
'lowercase', 'lowercase',
'email', 'email',

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $request->user()->id,
'name' => $request->user()->name,
'email' => $request->user()->email,
'favourites' => $request->user()->favourites()->get(['pathword'])->pluck('pathword')
];
}
}

21
app/Models/Author.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Author extends Model
{
protected $fillable = [
'name',
'pathword',
];
public function comics(): BelongsToMany
{
return $this->belongsToMany(Comic::class);
}
}

30
app/Models/Chapter.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Chapter extends Model
{
protected function casts(): array
{
return [
'chapter_uuid' => 'string',
'upstream_created_at' => 'date',
'metadata' => 'json',
];
}
public function comic(): BelongsTo
{
return $this->belongsTo(Comic::class);
}
public function images(): HasMany
{
return $this->hasMany(Image::class);
}
}

31
app/Models/Comic.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Comic extends Model
{
protected function casts(): array
{
return [
'uuid' => 'string',
'alias' => 'array',
'upstream_updated_at' => 'datetime:Y-m-d',
'metadata' => 'json',
];
}
public function authors(): BelongsToMany
{
return $this->belongsToMany(Author::class);
}
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
}
}

36
app/Models/Image.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Image extends Model
{
protected $fillable = [
'comic_id',
'chapter_id',
'order',
'url',
'metadata',
];
protected function casts(): array
{
return [
'metadata' => 'json',
];
}
public function chapter(): BelongsTo
{
return $this->belongsTo(Chapter::class);
}
public function comic(): BelongsTo
{
return $this->belongsTo(Comic::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ReadingHistory extends Model
{
public function comic(): BelongsTo
{
return $this->belongsTo(Comic::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function chapter(): BelongsTo
{
return $this->belongsTo(Chapter::class);
}
}

View File

@@ -2,14 +2,17 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
class User extends Authenticatable class User extends Authenticatable implements MustVerifyEmail
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable;
/** /**
@@ -45,4 +48,15 @@ class User extends Authenticatable
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
public function favourites(): BelongsToMany
{
return $this->belongsToMany(Comic::class, 'user_favourite')->withTimestamps();
}
public function readingHistories(): BelongsToMany
{
return $this->belongsToMany(Comic::class, 'reading_histories')->withTimestamps();
}
} }

336
app/Remote/CopyManga.php Normal file
View File

@@ -0,0 +1,336 @@
<?php
namespace App\Remote;
use DOMDocument;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Date;
/**
* CopyManga API
*/
class CopyManga
{
/**
* @var array Caching options
*/
protected array $options = [
'caching' => true,
'cachingTimeout' => 600
];
/**
* @var string API version
*/
protected string $apiVersion = "v3";
/**
* @var string
*/
protected string $url = "https://api.mangacopy.com/api/";
/**
* @var string Encryption for legacy image fetch
*/
protected string $encryptionKey = "xxxmanga.woo.key";
/**
* @var bool Use old method to fetch images list
*/
public bool $legacyImagesFetch = true;
/**
* @var string User Agent for API calls
*/
protected string $userAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 15_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1";
/**
* Default parameters
*
* @var array
*/
protected array $defaultParameters = [
'format' => 'json',
'platform' => 1
];
/**
* @var array parameters to be used
*/
protected array $parameters = [];
/**
* Build URL for method execute to run
*
* @param string $endpoint
* @param array $parameters
* @param bool $defaultParameters
* @return string
*/
protected function buildUrl(string $endpoint, array $parameters, bool $defaultParameters = true): string
{
// Merge parameters
if ($defaultParameters) {
$this->parameters = array_merge($this->defaultParameters, $parameters);
} else {
$this->parameters = $parameters;
}
return $this->url . $this->apiVersion . "/" . $endpoint . "?" . http_build_query($this->parameters);
}
/**
* Build URL for method execute to run
*
* @param string $endpoint
* @param array $parameters
* @param bool $defaultParameters
* @return string
*/
protected function legacyBuildUrl(string $endpoint, array $parameters = [], bool $defaultParameters = true): string
{
return "https://www.mangacopy.com/" . $endpoint;
}
/**
* Write to cache
*
* @param string $url
* @param array $value
* @return void
*/
protected function writeToCache(string $url, array $value): void
{
if ($this->options['caching']) {
Cache::add("URL_{$url}", array_merge($value, ['CACHE' => [
'CACHE' => true,
'CACHED_AT' => Date::now(),
'EXPIRE_AT' => Date::now()->addSeconds($this->options['cachingTimeout'])]
]),
$this->options['cachingTimeout']);
}
}
/**
* Exec fetching
*
* @param string $url
* @param string $method
* @param string $userAgent
* @return mixed|string
* @throws GuzzleException
*/
protected function execute(string $url, string $method = 'GET', string $userAgent = ""): mixed
{
if ($this->options['caching']) {
// Check cache exist
if (Cache::has("URL_{$url}")) {
$cache = Cache::get("URL_{$url}");
if (isset($cache['type']) && $cache['type'] == 'HTML') {
return $cache['response'];
} else {
return $cache;
}
}
}
$client = new Client(['headers' => ['User-Agent' => ($userAgent === "") ? $this->userAgent : $userAgent]]);
$options = [];
if ($method === 'OPTIONS') {
$options = ['headers' => ['Access-Control-Request-Method' => 'GET']];
} else {
$options = ['headers' => ['platform' => '1', 'version' => '2022.10.28', 'webp' => '0', 'region' => '0', 'Accept' => 'application/json']];
}
$response = $client->request($method, $url, $options);
if ($response->getStatusCode() !== 200) {
throw new Exception($response->getBody());
}
if ($userAgent !== "") {
// Directly send html to method to process
$html = $response->getBody()->getContents();
$this->writeToCache($url, ['response' => $html, 'type' => 'HTML']);
return $html;
}
if ($method !== "OPTIONS") {
$json = json_decode($response->getBody()->getContents(), true);
if (json_last_error() === JSON_ERROR_NONE) {
// Save to cache if needed
$this->writeToCache($url, $json['results']);
return $json['results'];
} else {
throw new Exception($json['code']);
}
} else {
return true;
}
}
/**
* Get tags available
*
* @return mixed|string
* @throws GuzzleException
*/
public function tags()
{
$parameters['type'] = 1; // From iOS site
$options = $this->execute($this->buildUrl("h5/filter/comic/tags", $parameters, false), 'OPTIONS');
return $this->execute($this->buildUrl("h5/filter/comic/tags", $parameters));
}
/**
* Get comics
*
* @param int $limit
* @param int $offset
* @param string $top
* @param array $parameters
* @return mixed|string
* @throws Exception|GuzzleException
*/
public function comics(int $limit = 28, int $offset = 0, string $top = '', array $parameters = []): mixed
{
if (isset($parameters['theme']) && $parameters['theme'] === 'all') {
$parameters['theme'] = "";
}
$parameters['limit'] = $limit;
$parameters['offset'] = $offset;
$parameters['top'] = $top;
return $this->execute($this->buildUrl("comics", $parameters));
}
/**
* Get comic info
*
* @param string $comic
* @param array $parameters
* @return mixed|string
* @throws Exception|GuzzleException
*/
public function comic(string $comic, array $parameters = []): mixed
{
return $this->execute($this->buildUrl("comic2/{$comic}", $parameters));
}
/**
* Get comic chapters
*
* @param string $comic
* @param int $limit
* @param int $offset
* @param array $parameters
* @param string $group
* @return mixed|string
* @throws GuzzleException
*/
public function chapters(string $comic, int $limit = 200, int $offset = 0, array $parameters = [], string $group = "default"): mixed
{
$parameters['limit'] = $limit;
$parameters['offset'] = $offset;
$options = $this->execute($this->buildUrl("comic/{$comic}/group/{$group}/chapters", $parameters, false), 'OPTIONS');
return $this->execute($this->buildUrl("comic/{$comic}/group/{$group}/chapters", $parameters));
}
/**
* Sort images by order
*
* @param array $arrayToOrder
* @param array $keys
* @return array
*/
protected function sort(array $arrayToOrder, array $keys): array
{
$output = [];
foreach ($keys as $key => $value) {
$output[$value] = $arrayToOrder[$key];
}
ksort($output);
return $output;
}
/**
* @param string $comic
* @param string $chapter
* @return array
* @throws GuzzleException
*/
public function legacyChapter(string $comic, string $chapter): array
{
$userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36";
$responses = $this->execute($this->legacyBuildUrl("comic/{$comic}/chapter/{$chapter}"), "GET", $userAgent);
// Get Content Key
$dom = new DOMDocument();
$dom->loadHTML($responses);
$dataNode = $dom->getElementsByTagName("div");
$encryptedData = "";
foreach ($dataNode as $node) {
if ($node->getAttribute("class") === 'imageData') {
$encryptedData = $node->attributes->item(1)->value;
break;
}
}
// Decrypt content
$content = $this->decrypt($encryptedData);
return json_decode($content, true);
}
/**
* Decrypt content of AES-128-CBC
*
* @param string $payload
* @return bool|string
*/
protected function decrypt(string $payload): bool|string
{
// Decrypt content
$cipher = "AES-128-CBC";
$ivLength = openssl_cipher_iv_length($cipher);
$iv = substr($payload, 0, $ivLength); // First 16 char
$encryptedData = hex2bin(substr($payload, $ivLength)); // Rest of content, convert to binary
return openssl_decrypt($encryptedData, $cipher, $this->encryptionKey, OPENSSL_RAW_DATA, $iv);
}
/**
* Get image for specific chapter of comic
*
* @param string $comic
* @param string $chapter
* @param array $parameters
* @return array
* @throws Exception|GuzzleException
*/
public function chapter(string $comic, string $chapter, array $parameters = []): array
{
$responses = $this->execute($this->buildUrl("comic/{$comic}/chapter2/{$chapter}", $parameters));
$responses['sorted'] = $this->sort($responses['chapter']['contents'], $responses['chapter']['words']);
if ($this->legacyImagesFetch) {
$responses['sorted'] = $this->legacyChapter($comic, $chapter);
}
return $responses;
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Remote;
use App\Enum\MimeType;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ImageFetcher
{
private array $options = [
'cache' => [
'enabled' => true,
'prefix' => 'image_fetcher_',
'ttl' => 3600,
],
'http' => [
'timeout' => 60,
'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
]
];
public function __construct(private readonly string $url = "")
{
// Global useragent
Http::globalRequestMiddleware(fn ($request) => $request->withHeader(
'User-Agent', $this->options['http']['userAgent']
));
}
public function forget(): bool
{
if (!$this->options['cache']['enabled']) {
return false;
}
return Cache::forget($this->cacheName($this->url));
}
public function getExtension(): string
{
$path = parse_url($this->url, PHP_URL_PATH);
return pathinfo($path, PATHINFO_EXTENSION);
}
public function getMimeType(): MimeType
{
return MimeType::{$this->getExtension()};
}
private function cacheName(string $url): string
{
return $this->options['cache']['prefix'] . base64_encode($url);
}
/**
* @return bool
*/
protected function isCached(): bool
{
return $this->options['cache']['enabled'] && Cache::has($this->cacheName($this->url));
}
/**
* @throws ConnectionException
*/
public function fetch()
{
if ($this->isCached()) {
return Cache::get($this->cacheName($this->url));
}
$response = Http::timeout($this->options['http']['timeout'])->get($this->url);
$image = $response->body();
// Write to cache
if ($this->options['cache']['enabled']) {
Cache::add($this->cacheName($this->url), $image, $this->options['cache']['ttl']);
}
return $image;
}
}

View File

@@ -1,21 +1,24 @@
<?php <?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\HandleInertiaRequests::class, HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, AddLinkHeadersForPreloadedAssets::class,
]); ]);
$middleware->statefulApi();
// //
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {

BIN
bun.lockb Executable file

Binary file not shown.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -7,7 +7,9 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"inertiajs/inertia-laravel": "^1.0", "ext-dom": "*",
"ext-openssl": "*",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^11.31", "laravel/framework": "^11.31",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",

160
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "74ab6cb89662a515eab3d0e8595bacf9", "content-hash": "b63241c5adeaff665ae634a61377fcc6",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -445,16 +445,16 @@
}, },
{ {
"name": "egulias/email-validator", "name": "egulias/email-validator",
"version": "4.0.2", "version": "4.0.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/egulias/EmailValidator.git", "url": "https://github.com/egulias/EmailValidator.git",
"reference": "ebaaf5be6c0286928352e054f2d5125608e5405e" "reference": "b115554301161fa21467629f1e1391c1936de517"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ebaaf5be6c0286928352e054f2d5125608e5405e", "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b115554301161fa21467629f1e1391c1936de517",
"reference": "ebaaf5be6c0286928352e054f2d5125608e5405e", "reference": "b115554301161fa21467629f1e1391c1936de517",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -500,7 +500,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/egulias/EmailValidator/issues", "issues": "https://github.com/egulias/EmailValidator/issues",
"source": "https://github.com/egulias/EmailValidator/tree/4.0.2" "source": "https://github.com/egulias/EmailValidator/tree/4.0.3"
}, },
"funding": [ "funding": [
{ {
@@ -508,7 +508,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2023-10-06T06:47:41+00:00" "time": "2024-12-27T00:36:43+00:00"
}, },
{ {
"name": "fruitcake/php-cors", "name": "fruitcake/php-cors",
@@ -1056,28 +1056,29 @@
}, },
{ {
"name": "inertiajs/inertia-laravel", "name": "inertiajs/inertia-laravel",
"version": "v1.3.2", "version": "v2.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/inertiajs/inertia-laravel.git", "url": "https://github.com/inertiajs/inertia-laravel.git",
"reference": "7e6a030ffab315099782a4844a2175455f511c68" "reference": "0259e37f802bc39c814c42ba92c04ada17921f70"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/7e6a030ffab315099782a4844a2175455f511c68", "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/0259e37f802bc39c814c42ba92c04ada17921f70",
"reference": "7e6a030ffab315099782a4844a2175455f511c68", "reference": "0259e37f802bc39c814c42ba92c04ada17921f70",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-json": "*", "ext-json": "*",
"laravel/framework": "^8.74|^9.0|^10.0|^11.0", "laravel/framework": "^10.0|^11.0",
"php": "^7.3|~8.0.0|~8.1.0|~8.2.0|~8.3.0|~8.4.0", "php": "^8.1.0",
"symfony/console": "^5.3|^6.0|^7.0" "symfony/console": "^6.2|^7.0"
}, },
"require-dev": { "require-dev": {
"laravel/pint": "^1.16",
"mockery/mockery": "^1.3.3", "mockery/mockery": "^1.3.3",
"orchestra/testbench": "^6.45|^7.44|^8.25|^9.3", "orchestra/testbench": "^8.0|^9.2",
"phpunit/phpunit": "^8.0|^9.5.8|^10.4", "phpunit/phpunit": "^10.4|^11.0",
"roave/security-advisories": "dev-master" "roave/security-advisories": "dev-master"
}, },
"suggest": { "suggest": {
@@ -1089,9 +1090,6 @@
"providers": [ "providers": [
"Inertia\\ServiceProvider" "Inertia\\ServiceProvider"
] ]
},
"branch-alias": {
"dev-master": "1.x-dev"
} }
}, },
"autoload": { "autoload": {
@@ -1120,7 +1118,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/inertiajs/inertia-laravel/issues", "issues": "https://github.com/inertiajs/inertia-laravel/issues",
"source": "https://github.com/inertiajs/inertia-laravel/tree/v1.3.2" "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.0"
}, },
"funding": [ "funding": [
{ {
@@ -1128,20 +1126,20 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-12-05T14:52:50+00:00" "time": "2024-12-13T02:48:29+00:00"
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v11.35.1", "version": "v11.36.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "dcfa130ede1a6fa4343dc113410963e791ad34fb" "reference": "df06f5163f4550641fdf349ebc04916a61135a64"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/dcfa130ede1a6fa4343dc113410963e791ad34fb", "url": "https://api.github.com/repos/laravel/framework/zipball/df06f5163f4550641fdf349ebc04916a61135a64",
"reference": "dcfa130ede1a6fa4343dc113410963e791ad34fb", "reference": "df06f5163f4550641fdf349ebc04916a61135a64",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1162,7 +1160,7 @@
"guzzlehttp/uri-template": "^1.0", "guzzlehttp/uri-template": "^1.0",
"laravel/prompts": "^0.1.18|^0.2.0|^0.3.0", "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0",
"laravel/serializable-closure": "^1.3|^2.0", "laravel/serializable-closure": "^1.3|^2.0",
"league/commonmark": "^2.2.1", "league/commonmark": "^2.6",
"league/flysystem": "^3.25.1", "league/flysystem": "^3.25.1",
"league/flysystem-local": "^3.25.1", "league/flysystem-local": "^3.25.1",
"league/uri": "^7.5.1", "league/uri": "^7.5.1",
@@ -1177,7 +1175,7 @@
"symfony/console": "^7.0.3", "symfony/console": "^7.0.3",
"symfony/error-handler": "^7.0.3", "symfony/error-handler": "^7.0.3",
"symfony/finder": "^7.0.3", "symfony/finder": "^7.0.3",
"symfony/http-foundation": "^7.0.3", "symfony/http-foundation": "^7.2.0",
"symfony/http-kernel": "^7.0.3", "symfony/http-kernel": "^7.0.3",
"symfony/mailer": "^7.0.3", "symfony/mailer": "^7.0.3",
"symfony/mime": "^7.0.3", "symfony/mime": "^7.0.3",
@@ -1343,7 +1341,7 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2024-12-12T18:25:58+00:00" "time": "2024-12-17T22:32:08+00:00"
}, },
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
@@ -1406,16 +1404,16 @@
}, },
{ {
"name": "laravel/sanctum", "name": "laravel/sanctum",
"version": "v4.0.6", "version": "v4.0.7",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/sanctum.git", "url": "https://github.com/laravel/sanctum.git",
"reference": "9e069e36d90b1e1f41886efa0fe9800a6b354694" "reference": "698064236a46df016e64a7eb059b1414e0b281df"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/9e069e36d90b1e1f41886efa0fe9800a6b354694", "url": "https://api.github.com/repos/laravel/sanctum/zipball/698064236a46df016e64a7eb059b1414e0b281df",
"reference": "9e069e36d90b1e1f41886efa0fe9800a6b354694", "reference": "698064236a46df016e64a7eb059b1414e0b281df",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1466,20 +1464,20 @@
"issues": "https://github.com/laravel/sanctum/issues", "issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum" "source": "https://github.com/laravel/sanctum"
}, },
"time": "2024-11-26T21:18:33+00:00" "time": "2024-12-11T16:40:21+00:00"
}, },
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.0", "version": "v2.0.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/serializable-closure.git", "url": "https://github.com/laravel/serializable-closure.git",
"reference": "0d8d3d8086984996df86596a86dea60398093a81" "reference": "613b2d4998f85564d40497e05e89cb6d9bd1cbe8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/0d8d3d8086984996df86596a86dea60398093a81", "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/613b2d4998f85564d40497e05e89cb6d9bd1cbe8",
"reference": "0d8d3d8086984996df86596a86dea60398093a81", "reference": "613b2d4998f85564d40497e05e89cb6d9bd1cbe8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1527,7 +1525,7 @@
"issues": "https://github.com/laravel/serializable-closure/issues", "issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure" "source": "https://github.com/laravel/serializable-closure"
}, },
"time": "2024-11-19T01:38:44+00:00" "time": "2024-12-16T15:26:28+00:00"
}, },
{ {
"name": "laravel/tinker", "name": "laravel/tinker",
@@ -2251,16 +2249,16 @@
}, },
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.8.2", "version": "3.8.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/briannesbitt/Carbon.git", "url": "https://github.com/briannesbitt/Carbon.git",
"reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947" "reference": "129700ed449b1f02d70272d2ac802357c8c30c58"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e1268cdbc486d97ce23fef2c666dc3c6b6de9947", "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/129700ed449b1f02d70272d2ac802357c8c30c58",
"reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947", "reference": "129700ed449b1f02d70272d2ac802357c8c30c58",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2292,10 +2290,6 @@
], ],
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": {
"dev-master": "3.x-dev",
"dev-2.x": "2.x-dev"
},
"laravel": { "laravel": {
"providers": [ "providers": [
"Carbon\\Laravel\\ServiceProvider" "Carbon\\Laravel\\ServiceProvider"
@@ -2305,6 +2299,10 @@
"includes": [ "includes": [
"extension.neon" "extension.neon"
] ]
},
"branch-alias": {
"dev-2.x": "2.x-dev",
"dev-master": "3.x-dev"
} }
}, },
"autoload": { "autoload": {
@@ -2353,7 +2351,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-11-07T17:46:48+00:00" "time": "2024-12-27T09:25:35+00:00"
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
@@ -3690,12 +3688,12 @@
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": { "branch-alias": {
"dev-main": "3.5-dev" "dev-main": "3.5-dev"
},
"thanks": {
"name": "symfony/contracts",
"url": "https://github.com/symfony/contracts"
} }
}, },
"autoload": { "autoload": {
@@ -3913,12 +3911,12 @@
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": { "branch-alias": {
"dev-main": "3.5-dev" "dev-main": "3.5-dev"
},
"thanks": {
"name": "symfony/contracts",
"url": "https://github.com/symfony/contracts"
} }
}, },
"autoload": { "autoload": {
@@ -5191,12 +5189,12 @@
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": { "branch-alias": {
"dev-main": "3.5-dev" "dev-main": "3.5-dev"
},
"thanks": {
"name": "symfony/contracts",
"url": "https://github.com/symfony/contracts"
} }
}, },
"autoload": { "autoload": {
@@ -5451,12 +5449,12 @@
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": { "branch-alias": {
"dev-main": "3.5-dev" "dev-main": "3.5-dev"
},
"thanks": {
"name": "symfony/contracts",
"url": "https://github.com/symfony/contracts"
} }
}, },
"autoload": { "autoload": {
@@ -5739,31 +5737,33 @@
}, },
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.2.7", "version": "v2.3.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
"reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb" "reference": "0d72ac1c00084279c1816675284073c5a337c20d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/83ee6f38df0a63106a9e4536e3060458b74ccedb", "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d",
"reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb", "reference": "0d72ac1c00084279c1816675284073c5a337c20d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-dom": "*", "ext-dom": "*",
"ext-libxml": "*", "ext-libxml": "*",
"php": "^5.5 || ^7.0 || ^8.0", "php": "^7.4 || ^8.0",
"symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" "symfony/css-selector": "^5.4 || ^6.0 || ^7.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^7.5 || ^8.5.21 || ^9.5.10" "phpstan/phpstan": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^8.5.21 || ^9.5.10"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "2.2.x-dev" "dev-master": "2.x-dev"
} }
}, },
"autoload": { "autoload": {
@@ -5786,9 +5786,9 @@
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
"support": { "support": {
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
"source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.2.7" "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0"
}, },
"time": "2023-12-08T13:03:43+00:00" "time": "2024-12-21T16:25:41+00:00"
}, },
{ {
"name": "vlucas/phpdotenv", "name": "vlucas/phpdotenv",
@@ -6453,16 +6453,16 @@
}, },
{ {
"name": "laravel/breeze", "name": "laravel/breeze",
"version": "v2.2.6", "version": "v2.3.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/breeze.git", "url": "https://github.com/laravel/breeze.git",
"reference": "907b12160d1b8b8213e7e2e011987fffb5567edc" "reference": "d59702967b9ae21879df905d691a50132966c4ff"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/breeze/zipball/907b12160d1b8b8213e7e2e011987fffb5567edc", "url": "https://api.github.com/repos/laravel/breeze/zipball/d59702967b9ae21879df905d691a50132966c4ff",
"reference": "907b12160d1b8b8213e7e2e011987fffb5567edc", "reference": "d59702967b9ae21879df905d691a50132966c4ff",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6510,7 +6510,7 @@
"issues": "https://github.com/laravel/breeze/issues", "issues": "https://github.com/laravel/breeze/issues",
"source": "https://github.com/laravel/breeze" "source": "https://github.com/laravel/breeze"
}, },
"time": "2024-11-20T15:01:15+00:00" "time": "2024-12-14T21:21:42+00:00"
}, },
{ {
"name": "laravel/pail", "name": "laravel/pail",
@@ -9284,7 +9284,9 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^8.2" "php": "^8.2",
"ext-dom": "*",
"ext-openssl": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"

34
config/cors.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];

83
config/sanctum.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@@ -2,12 +2,13 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> * @extends Factory<User>
*/ */
class UserFactory extends Factory class UserFactory extends Factory
{ {

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->string('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('comics', function (Blueprint $table) {
$table->id();
$table->string('pathword')->unique();
$table->uuid('uuid')->nullable();
$table->string('name')->index();
$table->json('alias')->nullable();
$table->text('description')->nullable();
$table->string('cover')->nullable();
$table->date('upstream_updated_at');
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('comics');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('chapters', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('comic_id')->index();
$table->uuid('chapter_uuid')->unique();
$table->string('name');
$table->integer('order');
$table->date('upstream_created_at');
$table->json('metadata');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('chapters');
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('comic_id');
$table->unsignedBigInteger('chapter_id');
$table->integer('order');
$table->text('url')->unique();
$table->json('metadata');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('images');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('authors', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('pathword')->nullable();
$table->timestamps();
});
Schema::create('author_comic', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('comic_id');
$table->unsignedInteger('author_id');
});
}
public function down(): void
{
Schema::dropIfExists('authors');
Schema::dropIfExists('author_comic');
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('user_favourite', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id')->index();
$table->unsignedBigInteger('comic_id')->index();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('user_favourite');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('reading_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('comic_id');
$table->foreignId('user_id');
$table->foreignId('chapter_id');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('reading_histories');
}
};

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"jsx": "react",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["resources/js/*"], "@/*": ["resources/js/*"],

1574
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,18 +6,41 @@
"dev": "vite" "dev": "vite"
}, },
"devDependencies": { "devDependencies": {
"@headlessui/react": "^2.0.0", "@headlessui/react": "^2.2.0",
"@inertiajs/react": "^1.0.0", "@inertiajs/react": "^2.0.0",
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.9",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.12", "autoprefixer": "^10.4.20",
"axios": "^1.7.4", "axios": "^1.7.9",
"concurrently": "^9.0.1", "concurrently": "^9.1.1",
"laravel-vite-plugin": "^1.0", "laravel-vite-plugin": "^1.1.1",
"postcss": "^8.4.31", "postcss": "^8.4.49",
"react": "^18.2.0", "react": "^18.3.1",
"react-dom": "^18.2.0", "react-dom": "^18.3.1",
"tailwindcss": "^3.2.1", "tailwindcss": "^3.4.17",
"vite": "^6.0" "vite": "^6.0.6"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lodash": "^4.17.21",
"lucide-react": "^0.468.0",
"react-hook-form": "^7.54.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
} }
} }

View File

@@ -1,3 +1,82 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,75 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
console.log(storageKey)
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
// @ts-ignore
return (
<ThemeProviderContext.Provider {...props} value={ value }>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
console.log(context);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} />
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,134 @@
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem } from '@/components/ui/sidebar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { BadgeCheck, ChevronsUpDown, Star, History, ChevronDown, LogOut, ChevronRight } from 'lucide-react';
import { Link, usePage } from '@inertiajs/react';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
export function AppSidebar({ auth }) {
const { tags } = usePage().props;
const SidebarItem = (props) => {
const searchParams = new URL(window.location).searchParams;
const isActive = (!searchParams.has(props.query) && props.name === 'All') || (searchParams.has(props.query) && searchParams.get(props.query) === props.path_word);
// To current, replace
if (searchParams.has(props.query)) {
searchParams.set(props.query, props.path_word);
} else {
searchParams.set(props.query, props.path_word);
}
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={ isActive }>
<Link
href={ "/?" + searchParams.toString() }><span>{ props.name }</span></Link>
</SidebarMenuButton>
{ props.count && <SidebarMenuBadge>{ props.count }</SidebarMenuBadge> }
</SidebarMenuItem>
);
};
return (
<Sidebar>
<SidebarContent>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
Tags <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
<SidebarItem path_word="all" query="tag" name="All" />
{ tags.theme.map(item => <SidebarItem query="tag" key={ item.path_word } { ...item } />) }
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
<Collapsible className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
Region <ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
<SidebarItem path_word="all" query="top" name="All" />
{ tags.top.map(item => <SidebarItem query="top" key={ item.path_word } { ...item } />) }
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg">
{ auth.user.name.slice(0, 1).toLocaleUpperCase() }
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{ auth.user.name }
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={ 4 }
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarFallback className="rounded-lg">
{ auth.user.name.slice(0, 1).toLocaleUpperCase() }
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{ auth.user.name }
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild><Link href={ route('profile.edit') }><BadgeCheck /> Profile</Link></DropdownMenuItem>
<DropdownMenuItem asChild><Link href={ route('comics.favourites') }><Star /> Favourites</Link></DropdownMenuItem>
<DropdownMenuItem><History /> History</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild><Link method="post" href={ route('logout') }><LogOut /> Log out</Link></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props} />
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props} />
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef(
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props} />
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props} />
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
(<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props} />)
);
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props} />
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,94 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />)
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,133 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { Controller, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
const FormFieldContext = React.createContext({})
const FormField = (
{
...props
}
) => {
return (
(<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>)
);
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const FormItemContext = React.createContext({})
const FormItem = React.forwardRef(({ className, ...props }, ref) => {
const id = React.useId()
return (
(<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>)
);
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
(<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props} />)
);
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
(<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props} />)
);
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
(<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props} />)
);
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
(<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}>
{body}
</p>)
);
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,101 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button";
import { Link } from "@inertiajs/react";
const Pagination = ({
className,
...props
}) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props} />
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props} />
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}) => (
<Link
aria-current={isActive ? "page" : undefined}
className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className)}
{...props} />
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,109 @@
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,619 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef((
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback((value) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}, [setOpenProp, open])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo(() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
return (
(<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style
}
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>)
);
})
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef((
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
(<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}>
{children}
</div>)
);
}
if (isMobile) {
return (
(<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
}
}
side={side}>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>)
);
}
return (
(<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)} />
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow">
{children}
</div>
</div>
</div>)
);
})
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>)
);
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props} />)
);
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef(({ className, ...props }, ref) => {
return (
(<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props} />)
);
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props} />)
);
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props} />)
);
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props} />)
);
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} />)
);
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
(<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props} />)
);
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props} />
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} />
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props} />
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef((
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} />
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
(<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip} />
</Tooltip>)
);
})
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props} />)
);
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, [])
return (
(<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width
}
} />
</div>)
);
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef(
({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
(<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
}
)
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
(<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />)
);
}
export { Skeleton }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} />
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,85 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props} />
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
return (
(<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props} />)
);
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props} />
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction };

View File

@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
(<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
(<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>)
);
})}
<ToastViewport />
</ToastProvider>)
);
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,87 @@
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/Components/ui/app-sidebar.jsx';
import { Separator } from "@radix-ui/react-separator";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { Toaster } from '@/components/ui/toaster';
import { Button } from '@/components/ui/button';
import { Moon, Settings, Sun } from 'lucide-react';
import { Link, usePage } from '@inertiajs/react';
import { useEffect, useState } from "react";
import { Tooltip, TooltipProvider, TooltipTrigger } from "@radix-ui/react-tooltip";
import { TooltipContent } from "@/Components/ui/tooltip.jsx";
export default function AppLayout({ auth, header, children, toolbar }) {
const getTheme = () => {
if (localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
return 'light';
}
const themeButtonOnclickHandler = (theme) => {
// Set local storage
localStorage.setItem('theme', theme);
setTheme(theme);
const root = window.document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(theme);
}
const [theme, setTheme] = useState(getTheme());
useEffect(() => {
getTheme();
setTheme(theme);
}, []);
return (
<SidebarProvider>
<AppSidebar auth={ auth } />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb className="hidden lg:block">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={ route('comics.index') }>Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{ header }
</BreadcrumbList>
</Breadcrumb>
<span className="flex gap-1 ml-auto justify-center content-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
{ theme === 'dark' && (
<Button variant="link" size="icon" onClick={ () => themeButtonOnclickHandler('light') }>
<Sun />
</Button>
) }
{ theme === 'light' && (
<Button variant="link" size="icon" onClick={ () => themeButtonOnclickHandler('dark') }>
<Moon />
</Button>
) }
</TooltipTrigger>
<TooltipContent>
<p>Toggle day / night mode</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{ toolbar }
</span>
</header>
<div className="w-full pt-3">
{ children }
<Toaster />
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,16 +1,9 @@
import ApplicationLogo from '@/Components/ApplicationLogo';
import { Link } from '@inertiajs/react'; import { Link } from '@inertiajs/react';
export default function GuestLayout({ children }) { export default function GuestLayout({ children }) {
return ( return (
<div className="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0 dark:bg-gray-900"> <div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
<div> <div className="w-full max-w-sm">
<Link href="/">
<ApplicationLogo className="h-20 w-20 fill-current text-gray-500" />
</Link>
</div>
<div className="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg dark:bg-gray-800">
{ children } { children }
</div> </div>
</div> </div>

View File

@@ -3,7 +3,10 @@ import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton'; import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput'; import TextInput from '@/Components/TextInput';
import GuestLayout from '@/Layouts/GuestLayout'; import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card.jsx";
import { Label } from "@/Components/ui/label.jsx";
import Checkbox from "@/Components/Checkbox.jsx";
export default function ConfirmPassword() { export default function ConfirmPassword() {
const { data, setData, post, processing, errors, reset } = useForm({ const { data, setData, post, processing, errors, reset } = useForm({
@@ -21,35 +24,34 @@ export default function ConfirmPassword() {
return ( return (
<GuestLayout> <GuestLayout>
<Head title="Confirm Password" /> <Head title="Confirm Password" />
<div className="flex flex-col gap-6">
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400"> <Card>
<CardHeader>
<CardTitle className="text-2xl">Confirm Password</CardTitle>
<CardDescription>
This is a secure area of the application. Please confirm your This is a secure area of the application. Please confirm your
password before continuing. password before continuing.
</div> </CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={ submit }> <form onSubmit={ submit }>
<div className="mt-4"> <div className="flex flex-col gap-6">
<InputLabel htmlFor="password" value="Password" /> <div className="grid gap-2">
<div className="flex items-center">
<TextInput <Label htmlFor="password">Password</Label>
id="password" </div>
type="password" <TextInput id="password" type="password" name="password" value={ data.password }
name="password" autoComplete="current-password"
value={data.password} onChange={ (e) => setData('password', e.target.value) } />
className="mt-1 block w-full"
isFocused={true}
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={ errors.password } className="mt-2" /> <InputError message={ errors.password } className="mt-2" />
</div> </div>
<PrimaryButton type="submit" disabled={ processing }
<div className="mt-4 flex items-center justify-end"> className="w-full">Confirm</PrimaryButton>
<PrimaryButton className="ms-4" disabled={processing}>
Confirm
</PrimaryButton>
</div> </div>
</form> </form>
</CardContent>
</Card>
</div>
</GuestLayout> </GuestLayout>
); );
} }

View File

@@ -2,7 +2,10 @@ import InputError from '@/Components/InputError';
import PrimaryButton from '@/Components/PrimaryButton'; import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput'; import TextInput from '@/Components/TextInput';
import GuestLayout from '@/Layouts/GuestLayout'; import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card.jsx";
import { Label } from "@/Components/ui/label.jsx";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function ForgotPassword({ status }) { export default function ForgotPassword({ status }) {
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
@@ -18,38 +21,47 @@ export default function ForgotPassword({ status }) {
return ( return (
<GuestLayout> <GuestLayout>
<Head title="Forgot Password" /> <Head title="Forgot Password" />
<div className="flex flex-col gap-6">
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400"> { status && (<Alert>
<AlertDescription>
{ status }
</AlertDescription>
</Alert> ) }
<Card>
<CardHeader>
<CardTitle className="text-2xl">Forgot Password</CardTitle>
<CardDescription>
Forgot your password? No problem. Just let us know your email Forgot your password? No problem. Just let us know your email
address and we will email you a password reset link that will address and we will email you a password reset link that will
allow you to choose a new one. allow you to choose a new one.
</div> </CardDescription>
</CardHeader>
{status && ( <CardContent>
<div className="mb-4 text-sm font-medium text-green-600 dark:text-green-400">
{status}
</div>
)}
<form onSubmit={ submit }> <form onSubmit={ submit }>
<TextInput <div className="flex flex-col gap-6">
id="email" <div className="grid gap-2">
type="email" <Label htmlFor="email">E-mail Address</Label>
name="email" <TextInput type="email" id="email" placeholder="me@yumj.in" value={ data.email }
value={data.email} onChange={ (e) => setData('email', e.target.value) } required />
className="mt-1 block w-full"
isFocused={true}
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={ errors.email } className="mt-2" /> <InputError message={ errors.email } className="mt-2" />
</div>
<div className="mt-4 flex items-center justify-end"> <PrimaryButton type="submit" disabled={ processing }
<PrimaryButton className="ms-4" disabled={processing}> className="w-full">Email Password Reset Link</PrimaryButton>
Email Password Reset Link </div>
</PrimaryButton> <div className="mt-4 text-center text-sm">
Return to &nbsp;
<Link href={ route('login') } className="underline underline-offset-4">
Login
</Link>
&nbsp; | &nbsp;
<Link href={ route('register') } className="underline underline-offset-4">
Sign up
</Link>
</div> </div>
</form> </form>
</CardContent>
</Card>
</div>
</GuestLayout> </GuestLayout>
); );
} }

View File

@@ -1,10 +1,12 @@
import Checkbox from '@/Components/Checkbox';
import InputError from '@/Components/InputError'; import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel'; import Checkbox from '@/Components/Checkbox';
import PrimaryButton from '@/Components/PrimaryButton'; import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput'; import TextInput from '@/Components/TextInput';
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, Link, useForm } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/Components/ui/card.jsx";
import GuestLayout from "@/Layouts/GuestLayout.jsx";
import { Alert, AlertDescription } from "@/Components/ui/alert.jsx";
export default function Login({ status, canResetPassword }) { export default function Login({ status, canResetPassword }) {
const { data, setData, post, processing, errors, reset } = useForm({ const { data, setData, post, processing, errors, reset } = useForm({
@@ -24,77 +26,68 @@ export default function Login({ status, canResetPassword }) {
return ( return (
<GuestLayout> <GuestLayout>
<Head title="Login" /> <Head title="Login" />
<div className="flex flex-col gap-6">
{status && ( { status && (<Alert>
<div className="mb-4 text-sm font-medium text-green-600"> <AlertDescription>
{ status } { status }
</div> </AlertDescription>
)} </Alert> ) }
<Card>
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={ submit }> <form onSubmit={ submit }>
<div> <div className="flex flex-col gap-6">
<InputLabel htmlFor="email" value="Email" /> <div className="grid gap-2">
<Label htmlFor="email">E-mail Address</Label>
<TextInput <TextInput type="email" id="email" placeholder="me@yumj.in" value={ data.email }
id="email" onChange={ (e) => setData('email', e.target.value) } required />
type="email"
name="email"
value={data.email}
className="mt-1 block w-full"
autoComplete="username"
isFocused={true}
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={ errors.email } className="mt-2" /> <InputError message={ errors.email } className="mt-2" />
</div> </div>
<div className="grid gap-2">
<div className="mt-4"> <div className="flex items-center">
<InputLabel htmlFor="password" value="Password" /> <Label htmlFor="password">Password</Label>
<Link href={ route('password.request') }
<TextInput className="ml-auto inline-block text-sm underline-offset-4 hover:underline">
id="password" Forgot your password?
type="password" </Link>
name="password" </div>
value={data.password} <TextInput id="password" type="password" name="password" value={ data.password }
className="mt-1 block w-full"
autoComplete="current-password" autoComplete="current-password"
onChange={(e) => setData('password', e.target.value)} onChange={ (e) => setData('password', e.target.value) } />
/>
<InputError message={ errors.password } className="mt-2" /> <InputError message={ errors.password } className="mt-2" />
</div> </div>
<div className="grid gap-2">
<div className="mt-4 block"> <div className="flex items-center space-x-2">
<label className="flex items-center">
<Checkbox <Checkbox
id="remember"
name="remember" name="remember"
checked={ data.remember } checked={ data.remember }
onChange={ (e) => onChange={ (e) =>
setData('remember', e.target.checked) setData('remember', e.target.checked)
} }
/> />
<span className="ms-2 text-sm text-gray-600 dark:text-gray-400"> <label htmlFor="remember"
Remember me className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Remember me</label>
</span>
</label>
</div> </div>
</div>
<div className="mt-4 flex items-center justify-end"> <PrimaryButton type="submit" disabled={ processing }
{canResetPassword && ( className="w-full">Login</PrimaryButton>
<Link </div>
href={route('password.request')} <div className="mt-4 text-center text-sm">
className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800" Don&apos;t have an account?&nbsp;
> <Link href={ route('register') } className="underline underline-offset-4">
Forgot your password? Sign up
</Link> </Link>
)}
<PrimaryButton className="ms-4" disabled={processing}>
Log in
</PrimaryButton>
</div> </div>
</form> </form>
</CardContent>
</Card>
</div>
</GuestLayout> </GuestLayout>
); );
} }

View File

@@ -1,9 +1,11 @@
import InputError from '@/Components/InputError'; import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton'; import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput'; import TextInput from '@/Components/TextInput';
import GuestLayout from '@/Layouts/GuestLayout'; import GuestLayout from '@/Layouts/GuestLayout';
import { Head, Link, useForm } from '@inertiajs/react'; import { Head, Link, useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card.jsx";
import { Label } from "@/Components/ui/label.jsx";
import Checkbox from "@/Components/Checkbox.jsx";
export default function Register() { export default function Register() {
const { data, setData, post, processing, errors, reset } = useForm({ const { data, setData, post, processing, errors, reset } = useForm({
@@ -24,97 +26,56 @@ export default function Register() {
return ( return (
<GuestLayout> <GuestLayout>
<Head title="Register" /> <Head title="Register" />
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Register</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={ submit }> <form onSubmit={ submit }>
<div> <div className="flex flex-col gap-6">
<InputLabel htmlFor="name" value="Name" /> <div className="grid gap-2">
<Label htmlFor="email">Username</Label>
<TextInput <TextInput id="name" placeholder="yumjin" value={ data.name }
id="name" onChange={ (e) => setData('name', e.target.value) } required />
name="name"
value={data.name}
className="mt-1 block w-full"
autoComplete="name"
isFocused={true}
onChange={(e) => setData('name', e.target.value)}
required
/>
<InputError message={ errors.name } className="mt-2" /> <InputError message={ errors.name } className="mt-2" />
</div> </div>
<div className="grid gap-2">
<div className="mt-4"> <Label htmlFor="email">E-mail Address</Label>
<InputLabel htmlFor="email" value="Email" /> <TextInput type="email" id="email" placeholder="me@yumj.in" value={ data.email }
onChange={ (e) => setData('email', e.target.value) } required />
<TextInput
id="email"
type="email"
name="email"
value={data.email}
className="mt-1 block w-full"
autoComplete="username"
onChange={(e) => setData('email', e.target.value)}
required
/>
<InputError message={ errors.email } className="mt-2" /> <InputError message={ errors.email } className="mt-2" />
</div> </div>
<div className="grid gap-2">
<div className="mt-4"> <div className="flex items-center">
<InputLabel htmlFor="password" value="Password" /> <Label htmlFor="password">Password</Label>
</div>
<TextInput <TextInput id="password" type="password" name="password" value={ data.password }
id="password" autoComplete="current-password"
type="password" onChange={ (e) => setData('password', e.target.value) } />
name="password"
value={data.password}
className="mt-1 block w-full"
autoComplete="new-password"
onChange={(e) => setData('password', e.target.value)}
required
/>
<InputError message={ errors.password } className="mt-2" /> <InputError message={ errors.password } className="mt-2" />
</div> </div>
<div className="grid gap-2">
<div className="mt-4"> <div className="flex items-center">
<InputLabel <Label htmlFor="password">Confirm Password</Label>
htmlFor="password_confirmation"
value="Confirm Password"
/>
<TextInput
id="password_confirmation"
type="password"
name="password_confirmation"
value={data.password_confirmation}
className="mt-1 block w-full"
autoComplete="new-password"
onChange={(e) =>
setData('password_confirmation', e.target.value)
}
required
/>
<InputError
message={errors.password_confirmation}
className="mt-2"
/>
</div> </div>
<TextInput id="password_confirmation" type="password" name="password_confirmation" value={ data.password_confirmation }
<div className="mt-4 flex items-center justify-end"> autoComplete="current-password"
<Link onChange={ (e) => setData('password_confirmation', e.target.value) } />
href={route('login')} <InputError message={ errors.password_confirmation } className="mt-2" />
className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800" </div>
> <PrimaryButton type="submit" disabled={ processing }
className="w-full">Register</PrimaryButton>
</div>
<div className="mt-4 text-center text-sm">
<Link href={ route('login') } className="underline underline-offset-4">
Already registered? Already registered?
</Link> </Link>
<PrimaryButton className="ms-4" disabled={processing}>
Register
</PrimaryButton>
</div> </div>
</form> </form>
</CardContent>
</Card>
</div>
</GuestLayout> </GuestLayout>
); );
} }

View File

@@ -1,9 +1,10 @@
import InputError from '@/Components/InputError'; import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import PrimaryButton from '@/Components/PrimaryButton'; import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput'; import TextInput from '@/Components/TextInput';
import GuestLayout from '@/Layouts/GuestLayout'; import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react'; import { Head, useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card.jsx";
import { Label } from "@/Components/ui/label.jsx";
export default function ResetPassword({ token, email }) { export default function ResetPassword({ token, email }) {
const { data, setData, post, processing, errors, reset } = useForm({ const { data, setData, post, processing, errors, reset } = useForm({
@@ -24,71 +25,43 @@ export default function ResetPassword({ token, email }) {
return ( return (
<GuestLayout> <GuestLayout>
<Head title="Reset Password" /> <Head title="Reset Password" />
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Reset Password</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={ submit }> <form onSubmit={ submit }>
<div> <div className="flex flex-col gap-6">
<InputLabel htmlFor="email" value="Email" /> <div className="grid gap-2">
<Label htmlFor="email">E-mail Address</Label>
<TextInput <TextInput type="email" id="email" placeholder="me@yumj.in" value={ data.email }
id="email" onChange={ (e) => setData('email', e.target.value) } required />
type="email"
name="email"
value={data.email}
className="mt-1 block w-full"
autoComplete="username"
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={ errors.email } className="mt-2" /> <InputError message={ errors.email } className="mt-2" />
</div> </div>
<div className="grid gap-2">
<div className="mt-4"> <div className="flex items-center">
<InputLabel htmlFor="password" value="Password" /> <Label htmlFor="password">Password</Label>
</div>
<TextInput <TextInput id="password" type="password" name="password" value={ data.password }
id="password" onChange={ (e) => setData('password', e.target.value) } />
type="password"
name="password"
value={data.password}
className="mt-1 block w-full"
autoComplete="new-password"
isFocused={true}
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={ errors.password } className="mt-2" /> <InputError message={ errors.password } className="mt-2" />
</div> </div>
<div className="grid gap-2">
<div className="mt-4"> <div className="flex items-center">
<InputLabel <Label htmlFor="password_confirmation">Password Confirmation</Label>
htmlFor="password_confirmation"
value="Confirm Password"
/>
<TextInput
type="password"
id="password_confirmation"
name="password_confirmation"
value={data.password_confirmation}
className="mt-1 block w-full"
autoComplete="new-password"
onChange={(e) =>
setData('password_confirmation', e.target.value)
}
/>
<InputError
message={errors.password_confirmation}
className="mt-2"
/>
</div> </div>
<TextInput id="password_confirmation" type="password" name="password_confirmation" value={ data.password_confirmation }
<div className="mt-4 flex items-center justify-end"> onChange={ (e) => setData('password_confirmation', e.target.value) } />
<PrimaryButton className="ms-4" disabled={processing}> <InputError message={ errors.password_confirmation } className="mt-2" />
Reset Password </div>
</PrimaryButton> <PrimaryButton type="submit" disabled={ processing }
className="w-full">Reset Password</PrimaryButton>
</div> </div>
</form> </form>
</CardContent>
</Card>
</div>
</GuestLayout> </GuestLayout>
); );
} }

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import { Head, Link, router } from '@inertiajs/react';
import { Moon, Plus, Star, ArrowDownNarrowWide, ArrowUpNarrowWide } from 'lucide-react';
import AppLayout from '@/Layouts/AppLayout.jsx';
import { BreadcrumbItem, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/hooks/use-toast';
import { Badge } from "@/Components/ui/badge.jsx";
export default function Chapters({ auth, comic, chapters }) {
const [group, setGroup] = useState('default');
const [favourites, setFavourites] = useState(auth.user.favourites);
const [ascending, setAscending] = useState(true);
const { toast } = useToast();
const favouriteOnClickHandler = (pathword) => {
axios.post(route('comics.postFavourite'), { pathword: pathword }).then(res => {
setFavourites(res.data);
});
toast({
title: "All set",
description: `${comic.comic.name} is now in / remove your favorite list.`,
});
}
const groupOnClickHandler = (pathword) => {
router.get(`/comic/${ comic.comic.path_word }?group=${ pathword }`, {}, {
only: ['chapters'],
preserveState: true
});
setGroup(pathword);
setAscending(true);
}
const ComicChapterLink = (props) => {
const isNew = Date.now() - Date.parse(props.datetime_created) < 6.048e+8;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button className="" size="sm" variant="outline" asChild>
<Link className="relative" href={ `/comic/${ comic.comic.path_word }/${ props.uuid }` }>
{ props.name }
{ isNew && <Plus size={ 16 } className="text-xs absolute right-0 top-0" /> }
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Updated: { props.datetime_created }</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const toggleAscending = (e) => {
setAscending(!ascending);
}
return (
<AppLayout auth={ auth } header={
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{ comic.comic.name }</BreadcrumbPage>
</BreadcrumbItem>
</>
}>
<Head>
<title>{ comic.comic.name }</title>
</Head>
<div className="p-3 pt-1">
<Card>
<CardHeader>
<CardTitle className="flex flex-row content-end items-center">
<Button onClick={ () => favouriteOnClickHandler(comic.comic.path_word) } size="icon" variant="ghost">
<Star fill={ favourites.includes(comic.comic.path_word) ? 'yellow': 'white' } />
</Button>
<span>{ comic.comic.name }</span>
</CardTitle>
</CardHeader>
<CardContent className="flex justify-start justify-items-stretch content-start items-start gap-5 flex-wrap">
<div className="basis-full lg:basis-2/12">
<img className="block object-fill w-full" src={ "/image/" + btoa(comic.comic.cover) }
alt={ comic.comic.name } />
</div>
<div className="basis-full lg:basis-9/12">
<table className="table-fixed w-full text-sm">
<tbody>
<tr>
<td className="text-right w-24 pr-3">Alias</td>
<td>{ comic.comic.alias }</td>
</tr>
<tr>
<td className="text-right pr-3">Category</td>
<td>
{ comic.comic.theme.map(t =>
<Badge key={ t.path_word } className="m-2" variant="outline">
<Link href={ route("comics.index", { tag: t.path_word }) }>{ t.name }</Link>
</Badge>
) }
</td>
</tr>
<tr>
<td className="text-right pr-3">Authors</td>
<td>{ comic.comic.author.map(a => <Badge key={ a.path_word } className="m-2" variant="outline"><Link>{ a.name }</Link></Badge>) }</td>
</tr>
<tr>
<td className="text-right pr-3">Description</td>
<td>{ comic.comic.brief }</td>
</tr>
<tr>
<td className="text-right pr-3">Updated At</td>
<td>{ comic.comic.datetime_updated }</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
<CardContent>
<Tabs defaultValue={ group } className="w-full">
<div className="flex">
<TabsList className={ `grid w-full grid-cols-${ Object.entries(comic.groups).length } ` }>
{ Object.entries(comic.groups).map((g, i) => (
<TabsTrigger onClick={ () => groupOnClickHandler(g[1].path_word) }
key={ g[1].path_word }
value={ g[1].path_word }>
{ g[1].name }
</TabsTrigger>
)) }
</TabsList>
<div>
<Button variant="link" size="icon" onClick={ () => toggleAscending() }>
{ ascending ? <ArrowDownNarrowWide /> : <ArrowUpNarrowWide /> }
</Button>
</div>
</div>
<TabsContent value={ group }>
<div className="w-full grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 xl:grid-cols-12 gap-1">
{ chapters.list.sort((a, b) => ascending ? (a.index - b.index) : (b.index - a.index)).map(c => (
<ComicChapterLink key={ c.uuid } { ...c } />
) ) }
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,89 @@
import { Head, Link, router } from '@inertiajs/react';
import { Button } from '@/components/ui/button';
import AppLayout from '@/Layouts/AppLayout.jsx';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Star } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { BreadcrumbItem, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { useToast } from "@/hooks/use-toast.js";
import { useState } from "react";
export default function Favourites({ auth, favourites }) {
const { toast } = useToast();
const [stateFavourites, setStateFavourites] = useState(favourites);
const favouriteOnClickHandler = (pathword) => {
axios.post(route('comics.postFavourite'), { pathword: pathword }).then(res => {
setStateFavourites(stateFavourites.filter(f => f.pathword !== pathword));
console.log(stateFavourites);
//setFavourites(res.data);
});
toast({
title: "All set",
description: `The comic is now removed from your favorite list.`,
});
}
const FavouriteCard = (props) => {
return (
<Card>
<CardContent className="flex gap-2">
<div className="basis-1/4 pt-3">
<div className="relative">
<Link href={ `/comic/${ props.pathword }` }>
<img className="block w-100 min-w-full object-fill" src={ "/image/" + btoa(props.cover) }
alt={ props.name } />
</Link>
<Button className="absolute bottom-0 right-0"
onClick={ () => favouriteOnClickHandler(props.pathword) } size="icon">
<Star fill='yellow' />
</Button>
</div>
</div>
<div className="basis-3/4">
<CardHeader>
<CardTitle><Link href={ `/comic/${ props.pathword }` }>{ props.name }</Link></CardTitle>
<div className="flex gap-2 items-end justify-between">
<p>
{ props.authors.map(a => <Badge key={ a.path_word } variant="outline"><Link>{ a.name }</Link></Badge>) }
</p>
<p className="text-right text-sm">
Updated: { props.upstream_updated_at }
</p>
</div>
<div className="pt-2">{ props.description }</div>
</CardHeader>
<CardFooter className="relative">
<Button className="w-full" asChild>
<Link href={ route('comics.read', [props.pathword, props.metadata.comic.last_chapter.uuid])}>
Read [{ props.metadata.comic.last_chapter.name }]
</Link>
</Button>
</CardFooter>
</div>
</CardContent>
</Card>
);
}
return (
<AppLayout auth={ auth } header={
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Favourites</BreadcrumbPage>
</BreadcrumbItem>
</>
}>
<Head>
<title>Favourites</title>
</Head>
<div className="p-3 pt-1 grid lg:grid-cols-2 sm:grid-cols-1 gap-2">
{ stateFavourites.map((favourite, i) => <FavouriteCard key={ i } {...favourite } /> ) }
</div>
</AppLayout>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { Head, Link } from '@inertiajs/react';
import { Star } from 'lucide-react';
import AppLayout from '@/Layouts/AppLayout.jsx';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from '@/components/ui/pagination';
import { useToast } from '@/hooks/use-toast.js';
export default function Index({ comics, offset, auth }) {
const url = new URL(window.location).searchParams;
const [favourites, setFavourites] = useState(auth.user.favourites);
const { toast } = useToast();
const favouriteOnClickHandler = (pathword) => {
axios.post(route('comics.postFavourite'), { pathword: pathword }).then(res => {
setFavourites(res.data);
});
toast({
title: "All set",
description: `${comics.list.filter(c => c.path_word === pathword)[0].name} is now in / remove your favorite list.`,
});
}
const ComicCard = (props) => (
<Card>
<CardHeader>
<div className="relative">
<Link href={ `/comic/${ props.path_word }` }>
<img className="block w-100 min-w-full object-fill" src={ "/image/" + btoa(props.cover) } alt={ props.name } />
</Link>
<Button className="absolute bottom-0 right-0" onClick={ () => favouriteOnClickHandler(props.path_word) } size="icon">
<Star fill={ favourites.includes(props.path_word) ? 'yellow': '' } />
</Button>
</div>
</CardHeader>
<CardContent>
<CardTitle><Link href={ `/comic/${ props.path_word }` }>{ props.name }</Link></CardTitle>
<CardDescription className="pt-2">
{ props.author.map(a => <Badge className="m-1" key={ a.path_word } variant="outline"><Link>{ a.name }</Link></Badge>) }
</CardDescription>
</CardContent>
</Card>
);
const ComicCards = (comics) => {
return comics.list.map((comic, i) => <ComicCard key={ i } { ...comic } />);
}
return (
<AppLayout auth={ auth }>
<Head>
<title>Home</title>
</Head>
<div className="p-3 pt-1 grid lg:grid-cols-6 sm:grid-cols-2 gap-2">
<ComicCards { ...comics } />
</div>
<Pagination className="justify-end pb-2">
<PaginationContent>
{ parseInt(offset) !== 0 &&
<PaginationItem>
<PaginationPrevious href={ `/?${url}` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) - 30 }} />
</PaginationItem>
}
<PaginationItem>
<PaginationNext href={ `/?${url}` } only={['comics', 'offset']} headers={{ offset: parseInt(offset) + 30 }} />
</PaginationItem>
</PaginationContent>
</Pagination>
</AppLayout>
);
}

View File

@@ -0,0 +1,291 @@
import { Head, Link, router } from '@inertiajs/react';
import AppLayout from '@/Layouts/AppLayout.jsx';
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from "@/Components/ui/breadcrumb.jsx";
import { Button } from "@/Components/ui/button.jsx";
import { ChevronFirst, ChevronLast, Rows3, Settings } from "lucide-react";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog";
import PrimaryButton from "@/Components/PrimaryButton.jsx";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipProvider, TooltipTrigger } from "@radix-ui/react-tooltip";
import { TooltipContent } from "@/Components/ui/tooltip.jsx";
import { throttle } from "lodash";
export default function Read({ auth, comic, chapter }) {
const [readingMode, setReadingMode] = useState('rtl'); // rtl, utd
const [isTwoPagesPerScreen, setIsTwoPagePerScreen] = useState(false);
const [currentImage, setCurrentImage] = useState(1);
const windowSize = useWindowSize();
const ref = useRef();
const [divDimensions, setDivDimensions] = useState([0, 0]);
const [loading, setLoading] = useState(true);
function useWindowSize() {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return size;
}
const toggleReadingMode = (e) => {
if (e) {
setReadingMode('utd');
} else {
setReadingMode('rtl');
}
}
const setViewPort = (e) => {
//console.log(e.target.childNodes);
//console.log(e.target.naturalHeight);
}
const ImageForComic = (img) => {
const imgRef = useRef();
const resizeImage = () => {
if (!imgRef.current || !ref.current) return;
const { naturalWidth, naturalHeight } = imgRef.current;
const containerWidth = ref.current.clientWidth;
const containerHeight = ref.current.clientHeight;
let width, height;
if (readingMode === "rtl") {
// Scale for RTL mode
const ratioWidth = naturalWidth / containerWidth;
const ratioHeight = naturalHeight / containerHeight;
const maxRatio = Math.max(ratioWidth, ratioHeight);
width = naturalWidth / maxRatio;
height = naturalHeight / maxRatio;
} else if (readingMode === "utd") {
// Scale for UTD mode
const ratio = divDimensions[1] < divDimensions[0] ? 0.33 : 1; // Example logic
const scaledWidth = containerWidth * ratio;
const scaledRatio = naturalWidth / scaledWidth;
width = naturalWidth / scaledRatio;
height = naturalHeight / scaledRatio;
}
// Apply dimensions directly
imgRef.current.style.width = `${width}px`;
imgRef.current.style.height = `${height}px`;
};
useEffect(() => {
resizeImage();
}, [readingMode, divDimensions]); // Recalculate when these dependencies change
const handleImageClick = (e) => {
if (readingMode === "utd") return;
const bounds = imgRef.current.getBoundingClientRect();
const percentage = (e.pageX - bounds.left) / imgRef.current.offsetWidth;
if (percentage < 0.45) {
if (img.innerKey === 0 && chapter.chapter.prev) {
router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.prev]));
} else {
document.getElementById(`image-${img.innerKey - 1}`)?.scrollIntoView();
}
} else if (percentage > 0.55) {
if (img.innerKey >= chapter.sorted.length - 1 && chapter.chapter.next) {
router.visit(route('comics.read', [comic.comic.path_word, chapter.chapter.next]));
} else {
document.getElementById(`image-${img.innerKey + 1}`)?.scrollIntoView();
}
}
};
return (<div className="basis-full">
<img
id={ `image-${img.innerKey}` }
ref={ imgRef }
className="m-auto comic-img"
src={ `/image/${btoa(img.url)}` }
onLoad={ resizeImage } // Resize image immediately on load
onClick={ handleImageClick }
alt={ comic.comic.name }
/>
</div>);
}
const Toolbar = () => {
return (
<>
<Dialog>
<DialogTrigger>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="link" size="icon">
<Settings />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Options</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</DialogTrigger>
<DialogContent className="max-w-[600px]">
<DialogHeader>
<DialogTitle>Options</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<label>Reading Mode</label>
<p>Turn on for UTD mode</p>
</div>
<Switch defaultChecked={ readingMode === "utd" }
onCheckedChange={ (e) => toggleReadingMode(e) } />
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<PrimaryButton>Done</PrimaryButton>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="link" size="icon" asChild>
<Link href={ route('comics.chapters', [comic.comic.path_word]) }>
<Rows3 />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Content Page</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{ chapter.chapter.prev && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="link" size="icon" asChild>
<Link href={ route('comics.read', [comic.comic.path_word, chapter.chapter.prev]) }>
<ChevronFirst />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Previous Chapter</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) }
{ chapter.chapter.next && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="link" size="icon" asChild>
<Link href={ route('comics.read', [comic.comic.path_word, chapter.chapter.next]) }>
<ChevronLast />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Next Chapter</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) }
<Button variant="ghost">
{ currentImage } / { chapter.sorted.length }
</Button>
</>
);
}
useEffect(() => {
setDivDimensions([ref.current.clientWidth, ref.current.clientHeight]);
}, [windowSize]);
useEffect(() => {
if (!ref.current) return;
const handleScroll = () => {
const containerScrollTop = ref.current.scrollTop; // Current scroll position of the container
const images = ref.current.querySelectorAll("img"); // Get all images
let visibleImageIndex = 0;
// Determine which image is visible based on scroll position
images.forEach((image, index) => {
const imageTop = image.offsetTop; // Distance from top of the container
const imageBottom = imageTop + image.offsetHeight;
// Check if the image is in the visible area
if (containerScrollTop + 80 >= imageTop && containerScrollTop < imageBottom) {
visibleImageIndex = index;
}
});
// Update the current image index
setCurrentImage(visibleImageIndex + 1);
};
const throttledHandleScroll = throttle(handleScroll, 100); // Throttle for performance
ref.current.addEventListener("scroll", throttledHandleScroll);
// Initial check for visible image
handleScroll();
return () => {
if (ref.current) {
ref.current.removeEventListener("scroll", throttledHandleScroll);
}
};
}, []);
return (
<AppLayout auth={ auth } header={
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={ route('comics.chapters', [comic.comic.path_word]) }>
{ comic.comic.name }
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{ chapter.chapter.name }</BreadcrumbPage>
</BreadcrumbItem>
</>
} toolbar={ <Toolbar /> }>
<Head>
<title>{ chapter.chapter.name } - { comic.comic.name }</title>
</Head>
<div className="p-3 pt-1 pb-1 flex flex-wrap justify-center" id="mvp" ref={ ref }
style={ { overflowAnchor: "none", height: "calc(100dvh - 90px)", overflowY: "scroll" } }>
{ chapter.sorted.map((img, j) => <ImageForComic key={ j } innerKey={ j } { ...img } />) }
</div>
</AppLayout>
);
}

View File

@@ -1,39 +1,43 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import DeleteUserForm from './Partials/DeleteUserForm'; import DeleteUserForm from './Partials/DeleteUserForm';
import UpdatePasswordForm from './Partials/UpdatePasswordForm'; import UpdatePasswordForm from './Partials/UpdatePasswordForm';
import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm'; import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm';
import AppLayout from "@/Layouts/AppLayout.jsx";
import { BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator } from "@/Components/ui/breadcrumb.jsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function Edit({ mustVerifyEmail, status }) { export default function Edit({ auth, mustVerifyEmail, status }) {
return ( return (
<AuthenticatedLayout <AppLayout auth={ auth } header={
header={ <>
<h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"> <BreadcrumbSeparator />
Profile <BreadcrumbItem>
</h2> <BreadcrumbLink>Profile</BreadcrumbLink>
} </BreadcrumbItem>
> </>
}>
<Head title="Profile" /> <Head title="Profile" />
<div className="py-3">
<div className="py-12"> <Tabs defaultValue="profile" className="mx-auto max-w-7xl space-y-6 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl space-y-6 sm:px-6 lg:px-8"> <TabsList className="grid w-full grid-cols-3">
<div className="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800"> <TabsTrigger value="profile">Profile Information</TabsTrigger>
<TabsTrigger value="password">Update Password</TabsTrigger>
<TabsTrigger value="deleteAccount">Delete Account</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<UpdateProfileInformationForm <UpdateProfileInformationForm
mustVerifyEmail={ mustVerifyEmail } mustVerifyEmail={ mustVerifyEmail }
status={ status } status={ status }
className="max-w-xl"
/> />
</TabsContent>
<TabsContent value="password">
<UpdatePasswordForm />
</TabsContent>
<TabsContent value="deleteAccount">
<DeleteUserForm />
</TabsContent>
</Tabs>
</div> </div>
</AppLayout>
<div className="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<UpdatePasswordForm className="max-w-xl" />
</div>
<div className="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<DeleteUserForm className="max-w-xl" />
</div>
</div>
</div>
</AuthenticatedLayout>
); );
} }

View File

@@ -1,14 +1,20 @@
import DangerButton from '@/Components/DangerButton'; import DangerButton from '@/Components/DangerButton';
import InputError from '@/Components/InputError'; import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import Modal from '@/Components/Modal';
import SecondaryButton from '@/Components/SecondaryButton'; import SecondaryButton from '@/Components/SecondaryButton';
import TextInput from '@/Components/TextInput'; import TextInput from '@/Components/TextInput';
import { useForm } from '@inertiajs/react'; import { useForm } from '@inertiajs/react';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import {
Dialog, DialogClose,
DialogContent,
DialogDescription, DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/Components/ui/card.jsx";
export default function DeleteUserForm({ className = '' }) { export default function DeleteUserForm({ className = '' }) {
const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false);
const passwordInput = useRef(); const passwordInput = useRef();
const { const {
@@ -23,98 +29,57 @@ export default function DeleteUserForm({ className = '' }) {
password: '', password: '',
}); });
const confirmUserDeletion = () => {
setConfirmingUserDeletion(true);
};
const deleteUser = (e) => { const deleteUser = (e) => {
e.preventDefault(); e.preventDefault();
destroy(route('profile.destroy'), { destroy(route('profile.destroy'), {
preserveScroll: true, preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.current.focus(), onError: () => passwordInput.current.focus(),
onFinish: () => reset(), onFinish: () => reset(),
}); });
}; };
const closeModal = () => {
setConfirmingUserDeletion(false);
clearErrors();
reset();
};
return ( return (
<section className={`space-y-6 ${className}`}> <Card>
<header> <CardHeader>
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100"> <CardTitle>Delete Account</CardTitle>
Delete Account <CardDescription className="pt-3">Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account,please download any data or information that you wish to retain.</CardDescription>
</h2> </CardHeader>
<CardFooter>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400"> <Dialog>
Once your account is deleted, all of its resources and data <form onSubmit={ deleteUser }>
will be permanently deleted. Before deleting your account, <DialogTrigger asChild>
please download any data or information that you wish to <DangerButton>Delete Account</DangerButton>
retain. </DialogTrigger>
</p> <DialogContent className="max-w-[800px]">
</header> <DialogHeader>
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
<DangerButton onClick={confirmUserDeletion}> <DialogDescription className="pt-3">
Delete Account
</DangerButton>
<Modal show={confirmingUserDeletion} onClose={closeModal}>
<form onSubmit={deleteUser} className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Are you sure you want to delete your account?
</h2>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Once your account is deleted, all of its resources and Once your account is deleted, all of its resources and
data will be permanently deleted. Please enter your data will be permanently deleted. Please enter your
password to confirm you would like to permanently delete password to confirm you would like to permanently delete
your account. your account.
</p> </DialogDescription>
</DialogHeader>
<div className="mt-6"> <div className="items-center gap-4">
<InputLabel <TextInput id="password" type="password" name="password" ref={ passwordInput }
htmlFor="password"
value="Password"
className="sr-only"
/>
<TextInput
id="password"
type="password"
name="password"
ref={passwordInput}
value={ data.password } value={ data.password }
onChange={(e) => onChange={ (e) => setData('password', e.target.value) }
setData('password', e.target.value) className="mt-1 block w-full" isFocused placeholder="Password" />
} <InputError essage={ errors.password } className="mt-2" />
className="mt-1 block w-3/4"
isFocused
placeholder="Password"
/>
<InputError
message={errors.password}
className="mt-2"
/>
</div> </div>
<DialogFooter className="mt-6 flex justify-end">
<div className="mt-6 flex justify-end"> <DialogClose asChild>
<SecondaryButton onClick={closeModal}> <SecondaryButton>Cancel</SecondaryButton>
Cancel </DialogClose>
</SecondaryButton> <DangerButton className="ms-3" onClick={ (e) => deleteUser(e) }>
<DangerButton className="ms-3" disabled={processing}>
Delete Account Delete Account
</DangerButton> </DangerButton>
</div> </DialogFooter>
</DialogContent>
</form> </form>
</Modal> </Dialog>
</section> </CardFooter>
</Card>
); );
} }

View File

@@ -5,6 +5,7 @@ import TextInput from '@/Components/TextInput';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { useForm } from '@inertiajs/react'; import { useForm } from '@inertiajs/react';
import { useRef } from 'react'; import { useRef } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/Components/ui/card.jsx";
export default function UpdatePasswordForm({ className = '' }) { export default function UpdatePasswordForm({ className = '' }) {
const passwordInput = useRef(); const passwordInput = useRef();
@@ -45,25 +46,16 @@ export default function UpdatePasswordForm({ className = '' }) {
}; };
return ( return (
<section className={className}> <Card>
<header> <form onSubmit={ updatePassword }>
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100"> <CardHeader>
Update Password <CardTitle>Update Password</CardTitle>
</h2> <CardDescription>Ensure your account is using a long, random password to stay secure.</CardDescription>
</CardHeader>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400"> <CardContent>
Ensure your account is using a long, random password to stay <div className="grid w-full items-center gap-4">
secure. <div className="flex flex-col space-y-1.5">
</p> <InputLabel htmlFor="current_password" value="Current Password" />
</header>
<form onSubmit={updatePassword} className="mt-6 space-y-6">
<div>
<InputLabel
htmlFor="current_password"
value="Current Password"
/>
<TextInput <TextInput
id="current_password" id="current_password"
ref={ currentPasswordInput } ref={ currentPasswordInput }
@@ -72,38 +64,26 @@ export default function UpdatePasswordForm({ className = '' }) {
setData('current_password', e.target.value) setData('current_password', e.target.value)
} }
type="password" type="password"
className="mt-1 block w-full" className="mt-1 block w-full" />
autoComplete="current-password"
/>
<InputError
message={errors.current_password}
className="mt-2"
/>
</div> </div>
<InputError className="mt-2" message={ errors.current_password } />
<div> </div>
<div className="grid w-full items-center gap-4 pt-3">
<div className="flex flex-col space-y-1.5">
<InputLabel htmlFor="password" value="New Password" /> <InputLabel htmlFor="password" value="New Password" />
<TextInput <TextInput
id="password" id="password"
ref={ passwordInput } ref={ passwordInput }
value={ data.password } value={ data.password }
onChange={ (e) => setData('password', e.target.value) } onChange={ (e) => setData('password', e.target.value) }
type="password" type="password"
className="mt-1 block w-full" className="mt-1 block w-full" />
autoComplete="new-password"
/>
<InputError message={errors.password} className="mt-2" />
</div> </div>
<InputError className="mt-2" message={ errors.password } />
<div> </div>
<InputLabel <div className="grid w-full items-center gap-4 pt-3">
htmlFor="password_confirmation" <div className="flex flex-col space-y-1.5">
value="Confirm Password" <InputLabel htmlFor="password_confirmation" value="Confirm Password" />
/>
<TextInput <TextInput
id="password_confirmation" id="password_confirmation"
value={ data.password_confirmation } value={ data.password_confirmation }
@@ -111,32 +91,23 @@ export default function UpdatePasswordForm({ className = '' }) {
setData('password_confirmation', e.target.value) setData('password_confirmation', e.target.value)
} }
type="password" type="password"
className="mt-1 block w-full" className="mt-1 block w-full" />
autoComplete="new-password"
/>
<InputError
message={errors.password_confirmation}
className="mt-2"
/>
</div> </div>
<InputError className="mt-2" message={ errors.password_confirmation } />
<div className="flex items-center gap-4"> </div>
</CardContent>
<CardFooter>
<PrimaryButton disabled={ processing }>Save</PrimaryButton> <PrimaryButton disabled={ processing }>Save</PrimaryButton>
<Transition <Transition
show={ recentlySuccessful } show={ recentlySuccessful }
enter="transition ease-in-out" enter="transition ease-in-out"
enterFrom="opacity-0" enterFrom="opacity-0"
leave="transition ease-in-out" leave="transition ease-in-out"
leaveTo="opacity-0" leaveTo="opacity-0">
> <p className="text-sm text-gray-600 dark:text-gray-400">Saved.</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Saved.
</p>
</Transition> </Transition>
</div> </CardFooter>
</form> </form>
</section> </Card>
); );
} }

View File

@@ -4,6 +4,14 @@ import PrimaryButton from '@/Components/PrimaryButton';
import TextInput from '@/Components/TextInput'; import TextInput from '@/Components/TextInput';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { Link, useForm, usePage } from '@inertiajs/react'; import { Link, useForm, usePage } from '@inertiajs/react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function UpdateProfileInformation({ export default function UpdateProfileInformation({
mustVerifyEmail, mustVerifyEmail,
@@ -25,63 +33,52 @@ export default function UpdateProfileInformation({
}; };
return ( return (
<section className={className}> <Card>
<header> <form onSubmit={ submit }>
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100"> <CardHeader>
Profile Information <CardTitle>Profile Information</CardTitle>
</h2> <CardDescription>Update your account's profile information and email address.</CardDescription>
</CardHeader>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400"> <CardContent>
Update your account's profile information and email address. <div className="grid w-full items-center gap-4">
</p> <div className="flex flex-col space-y-1.5">
</header>
<form onSubmit={submit} className="mt-6 space-y-6">
<div>
<InputLabel htmlFor="name" value="Name" /> <InputLabel htmlFor="name" value="Name" />
<TextInput <TextInput
id="name" id="name"
className="mt-1 block w-full" className="mt-1 block w-full"
value={ data.name } value={ data.name }
onChange={(e) => setData('name', e.target.value)} onChange={ (e) => setData('name', e.target.value) } />
required </div>
isFocused
autoComplete="name"
/>
<InputError className="mt-2" message={ errors.name } /> <InputError className="mt-2" message={ errors.name } />
</div> </div>
<div className="grid w-full items-center gap-4 pt-3">
<div> <div className="flex flex-col space-y-1.5">
<InputLabel htmlFor="email" value="Email" /> <InputLabel htmlFor="email" value="Email" />
<TextInput <TextInput
id="email" id="email"
type="email" type="email"
className="mt-1 block w-full" className="mt-1 block w-full"
value={ data.email } value={ data.email }
onChange={ (e) => setData('email', e.target.value) } onChange={ (e) => setData('email', e.target.value) }
required required />
autoComplete="username" </div>
/>
<InputError className="mt-2" message={ errors.email } /> <InputError className="mt-2" message={ errors.email } />
</div> </div>
{ mustVerifyEmail && user.email_verified_at === null && ( { mustVerifyEmail && user.email_verified_at === null && (
<div> <div className="grid w-full items-center gap-4 pt-3">
<div className="flex flex-col space-y-1.5">
<p className="mt-2 text-sm text-gray-800 dark:text-gray-200"> <p className="mt-2 text-sm text-gray-800 dark:text-gray-200">
Your email address is unverified. Your email address is unverified.
<Link <Link
href={ route('verification.send') } href={ route('verification.send') }
method="post" method="post"
as="button" as="button"
className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800" className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800">
>
Click here to re-send the verification email. Click here to re-send the verification email.
</Link> </Link>
</p> </p>
</div>
{ status === 'verification-link-sent' && ( { status === 'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600 dark:text-green-400"> <div className="mt-2 text-sm font-medium text-green-600 dark:text-green-400">
@@ -91,23 +88,19 @@ export default function UpdateProfileInformation({
) } ) }
</div> </div>
) } ) }
</CardContent>
<div className="flex items-center gap-4"> <CardFooter>
<PrimaryButton disabled={ processing }>Save</PrimaryButton> <PrimaryButton disabled={ processing }>Save</PrimaryButton>
<Transition <Transition
show={ recentlySuccessful } show={ recentlySuccessful }
enter="transition ease-in-out" enter="transition ease-in-out"
enterFrom="opacity-0" enterFrom="opacity-0"
leave="transition ease-in-out" leave="transition ease-in-out"
leaveTo="opacity-0" leaveTo="opacity-0">
> <p className="text-sm text-gray-600 dark:text-gray-400">Saved.</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Saved.
</p>
</Transition> </Transition>
</div> </CardFooter>
</form> </form>
</section> </Card>
); );
} }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange);
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,155 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST"
}
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString();
}
const toastTimeouts = new Map()
const addToRemoveQueue = (toastId) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state, action) => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t),
};
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
const listeners = []
let memoryState = { toasts: [] }
function dispatch(action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
function toast({
...props
}) {
const id = genId()
const update = (props) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
};
}, [state])
return {
...state,
toast,
dismiss: (toastId) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast }

View File

@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@@ -3,14 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'Laravel') }}</title> <title inertia>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@routes @routes
@viteReactRefresh @viteReactRefresh
@vite(['resources/js/app.jsx', "resources/js/Pages/{$page['component']}.jsx"]) @vite(['resources/js/app.jsx', "resources/js/Pages/{$page['component']}.jsx"])

8
routes/api.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');

View File

@@ -1,17 +1,26 @@
<?php <?php
use App\Http\Controllers\ComicController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
Route::get('/', function () { // Auth protected routes
return Inertia::render('Welcome', [ Route::controller(ComicController::class)->middleware('auth')->name('comics.')->group(function () {
'canLogin' => Route::has('login'), Route::get('/', 'index')->name('index');
'canRegister' => Route::has('register'), Route::get('/comic/{pathword}/{uuid}', 'read')->name('read');
'laravelVersion' => Application::VERSION, Route::get('/comic/{pathword}', 'chapters')->name('chapters');
'phpVersion' => PHP_VERSION, Route::get('/tags', 'tags')->name('tags');
]);
// Image
Route::get('/image/{url}', 'image')->name('image');
// Favourites show
Route::get('/favourites', 'favourites')->name('favourites');
// Toggle favourites
Route::post('/favourites', 'postFavourite')->name('postFavourite');
}); });
Route::get('/dashboard', function () { Route::get('/dashboard', function () {

View File

@@ -3,6 +3,7 @@ import forms from '@tailwindcss/forms';
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: ['class'],
content: [ content: [
'./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php',
'./storage/framework/views/*.php', './storage/framework/views/*.php',
@@ -13,10 +14,70 @@ export default {
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['Figtree', ...defaultTheme.fontFamily.sans], sans: [
'Figtree',
...defaultTheme.fontFamily.sans
]
}, },
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}, },
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
}
}
}, },
plugins: [forms], plugins: [forms, require("tailwindcss-animate")],
}; };