generated from Flycro/laravel-nuxt
feat: Multiple Systems
parent
22ea1930c4
commit
12d8f3913c
42
.env.example
42
.env.example
|
|
@ -1,8 +1,9 @@
|
|||
APP_NAME=LaravelNuxt
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_KEY=base64:nsRstTKasZXl9pyGadA3og==
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_PORT=8000
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
API_URL=http://127.0.0.1:8000
|
||||
API_LOCAL_URL=http://127.0.0.1:8000
|
||||
|
|
@ -22,12 +23,12 @@ LOG_STACK=single
|
|||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=pgsql
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=laravel
|
||||
DB_USERNAME=sail
|
||||
DB_PASSWORD=password
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
|
@ -45,19 +46,27 @@ CACHE_PREFIX=
|
|||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
PUSHER_APP_ID=Laravel
|
||||
PUSHER_APP_KEY=Laravel
|
||||
PUSHER_APP_SECRET=Laravel
|
||||
PUSHER_HOST=soketi
|
||||
PUSHER_PORT=6001
|
||||
PUSHER_SCHEME=http
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
|
|
@ -70,4 +79,11 @@ GOOGLE_CLIENT_ID=
|
|||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=https://127.0.0.1:8000/api/v1/login/google/callback
|
||||
|
||||
TELESCOPE_ENABLED=false
|
||||
TELESCOPE_ENABLED=true
|
||||
|
||||
SCOUT_DRIVER=meilisearch
|
||||
MEILISEARCH_HOST=http://meilisearch:7700
|
||||
|
||||
MEILISEARCH_NO_ANALYTICS=false
|
||||
|
||||
OCTANE_SERVER=frankenphp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BookRecommendationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$relations = [];
|
||||
if (request()->has('with')) {
|
||||
$relations = explode(',', request()->with);
|
||||
}
|
||||
return BookRecommendation::with($relations)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'book_name' => 'required|string|max:255',
|
||||
'author' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'isbn' => 'required|string|unique:book_recommendations,isbn',
|
||||
'pages' => 'required|integer',
|
||||
'status' => 'in:PENDING,COMPLETED,REJECTED,ACTIVE',
|
||||
'cover_image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
]);
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
if ($request->hasFile('cover_image')) {
|
||||
$imagePath = $request->file('cover_image')->store('cover_images', 'public');
|
||||
$data['cover_image'] = $imagePath;
|
||||
}
|
||||
|
||||
$bookRecommendation = BookRecommendation::create([...$request->all(), 'recommended_by' => auth()->id()]);
|
||||
return response()->json($bookRecommendation, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(string $id)
|
||||
{
|
||||
$relations = [];
|
||||
if (request()->has('with')) {
|
||||
$relations = explode(',', request()->with);
|
||||
}
|
||||
|
||||
$bookRecommendation = BookRecommendation::with($relations)->findOrFail($id);
|
||||
return response()->json($bookRecommendation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$bookRecommendation = BookRecommendation::findOrFail($id);
|
||||
$request->validate([
|
||||
'book_name' => 'string|max:255',
|
||||
'author' => 'string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'isbn' => 'string|unique:book_recommendations,isbn,'.$bookRecommendation->id,
|
||||
'pages' => 'integer',
|
||||
'recommended_by' => 'exists:users,id',
|
||||
'status' => 'in:PENDING,COMPLETED,REJECTED,ACTIVE',
|
||||
'cover_image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
|
||||
]);
|
||||
|
||||
|
||||
if ($bookRecommendation->recommended_by !== auth()->id() && !(auth()->user()->hasRole('admin')) ) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
if ($request->hasFile('cover_image')) {
|
||||
// Delete old image if exists
|
||||
if ($bookRecommendation->cover_image) {
|
||||
Storage::delete($bookRecommendation->cover_image);
|
||||
}
|
||||
|
||||
$imagePath = $request->file('cover_image')->store('cover_images', 'public');
|
||||
$data['cover_image'] = $imagePath;
|
||||
}
|
||||
|
||||
$bookRecommendation->update($data);
|
||||
|
||||
return response()->json($bookRecommendation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
{
|
||||
$bookRecommendation = BookRecommendation::findOrFail($id);
|
||||
if ($bookRecommendation->recommended_by !== auth()->id() && !(auth()->user()->hasRole('admin')) ) {
|
||||
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||
}
|
||||
$bookRecommendation->delete();
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use App\Models\Vote;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VoteController extends Controller
|
||||
{
|
||||
public function castVote(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'book_recommendation_id' => 'required|exists:book_recommendations,id',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->total_votes > 0) {
|
||||
$user->decrement('total_votes');
|
||||
|
||||
Vote::create([
|
||||
'user_id' => $user->id,
|
||||
'book_recommendation_id' => $request->book_recommendation_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Vote successfully cast.']);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'No remaining votes.'], 403);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BookRecommendation extends Model
|
||||
{
|
||||
protected $table = 'book_recommendations';
|
||||
|
||||
protected $fillable = [
|
||||
'book_name',
|
||||
'author',
|
||||
'description',
|
||||
'isbn',
|
||||
'pages',
|
||||
'recommended_by',
|
||||
'cover_image',
|
||||
'status',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the user that recommended the book.
|
||||
*/
|
||||
public function recommender()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'recommended_by');
|
||||
}
|
||||
|
||||
public function votes()
|
||||
{
|
||||
return $this->hasMany(Vote::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
'email',
|
||||
'avatar',
|
||||
'password',
|
||||
'total_votes',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -57,6 +58,11 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
return $this->hasMany(UserProvider::class);
|
||||
}
|
||||
|
||||
public function votes()
|
||||
{
|
||||
return $this->hasMany(Vote::class);
|
||||
}
|
||||
|
||||
public function mustVerifyEmail(): bool
|
||||
{
|
||||
return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Vote extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'book_recommendation_id',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function bookRecommendation()
|
||||
{
|
||||
return $this->belongsTo(BookRecommendation::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
"laravel/socialite": "^5.12",
|
||||
"laravel/tinker": "^2.9",
|
||||
"league/flysystem-aws-s3-v3": "^3.24",
|
||||
"predis/predis": "*",
|
||||
"spatie/laravel-permission": "^6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "22693d68e1669d8584aae373e4619f23",
|
||||
"content-hash": "ec31886b4063db8aa1efc22c187f614a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -3645,6 +3645,67 @@
|
|||
],
|
||||
"time": "2023-11-12T21:59:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "predis/predis",
|
||||
"version": "v2.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/predis/predis.git",
|
||||
"reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/predis/predis/zipball/b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1",
|
||||
"reference": "b1d3255ed9ad4d7254f9f9bba386c99f4bb983d1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.3",
|
||||
"phpstan/phpstan": "^1.9",
|
||||
"phpunit/phpunit": "^8.0 || ~9.4.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Predis\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Till Krüss",
|
||||
"homepage": "https://till.im",
|
||||
"role": "Maintainer"
|
||||
}
|
||||
],
|
||||
"description": "A flexible and feature-complete Redis client for PHP.",
|
||||
"homepage": "http://github.com/predis/predis",
|
||||
"keywords": [
|
||||
"nosql",
|
||||
"predis",
|
||||
"redis"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/predis/predis/issues",
|
||||
"source": "https://github.com/predis/predis/tree/v2.2.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/tillkruss",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-09-13T16:42:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
"version": "1.0.0",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ return new class extends Migration
|
|||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->integer('total_votes')->default(2);
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<?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('book_recommendations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('book_name');
|
||||
$table->string('author');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('isbn')->unique();
|
||||
$table->integer('pages')->unsigned();
|
||||
$table->string('cover_image')->nullable();
|
||||
$table->unsignedBigInteger('recommended_by');
|
||||
$table->enum('status', ['PENDING', 'COMPLETED', 'REJECTED','ACTIVE'])->default('PENDING');
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('recommended_by')->references('id')->on('users')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('book_recommendations');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?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('votes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('book_recommendation_id')->constrained()->onDelete('cascade');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('votes');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\BookRecommendation;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class BookRecommendationsTableSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Let's say we want to create 50 fake book recommendations
|
||||
for ($i = 0; $i < 50; $i++) {
|
||||
BookRecommendation::create([
|
||||
'book_name' => fake()->sentence($nbWords = 3, $variableNbWords = true),
|
||||
'author' => fake()->name,
|
||||
'description' => fake()->paragraph($nbSentences = 3, $variableNbSentences = true),
|
||||
'isbn' => fake()->isbn13(),
|
||||
'pages' => fake()->numberBetween($min = 100, $max = 1000),
|
||||
'recommended_by' => 1, // Adjust the range as necessary
|
||||
'cover_image' => null, // You could also simulate image paths if needed
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,5 +35,7 @@ class DatabaseSeeder extends Seeder
|
|||
$user->save(['timestamps' => false]);
|
||||
|
||||
$user->assignRole($role1);
|
||||
|
||||
$this->call(BookRecommendationsTableSeeder::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ services:
|
|||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=frankenphp --host=0.0.0.0 --admin-port=2019 --port=8000"
|
||||
SUPERVISOR_PHP_COMMAND: "/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan octane:start --server=frankenphp --host=0.0.0.0 --admin-port=2019 --port=8000 --watch"
|
||||
XDG_CONFIG_HOME: /var/www/html/config
|
||||
XDG_DATA_HOME: /var/www/html/data
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import antfu from '@antfu/eslint-config'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
|
||||
const compat = new FlatCompat()
|
||||
|
||||
export default antfu({
|
||||
rules: {
|
||||
'curly': ['error', 'all'],
|
||||
'node/prefer-global/process': ['error', 'always'],
|
||||
},
|
||||
}, ...compat.config({
|
||||
extends: ['plugin:tailwindcss/recommended'],
|
||||
rules: {
|
||||
'tailwindcss/no-custom-classname': 'off',
|
||||
'tailwindcss/migration-from-tailwind-2': 'off',
|
||||
},
|
||||
}))
|
||||
|
|
@ -5,7 +5,7 @@ export default defineNuxtConfig({
|
|||
$development: {
|
||||
ssr: true,
|
||||
devtools: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -23,13 +23,9 @@ export default defineNuxtConfig({
|
|||
},
|
||||
|
||||
routeRules: {
|
||||
'auth/verify': { ssr: false }
|
||||
'auth/verify': { ssr: false },
|
||||
},
|
||||
|
||||
css: [
|
||||
'@/assets/css/main.css',
|
||||
],
|
||||
|
||||
/**
|
||||
* @see https://v3.nuxtjs.org/api/configuration/nuxt.config#modules
|
||||
*/
|
||||
|
|
@ -39,7 +35,6 @@ export default defineNuxtConfig({
|
|||
'@nuxt/image',
|
||||
'@pinia/nuxt',
|
||||
'dayjs-nuxt',
|
||||
'nuxt-security',
|
||||
],
|
||||
|
||||
ui: {
|
||||
|
|
@ -48,28 +43,26 @@ export default defineNuxtConfig({
|
|||
|
||||
image: {
|
||||
domains: [
|
||||
process.env.API_URL || 'http://127.0.0.1:8000'
|
||||
process.env.API_URL || 'http://127.0.0.1:8000',
|
||||
],
|
||||
alias: {
|
||||
api: process.env.API_URL || 'http://127.0.0.1:8000'
|
||||
}
|
||||
api: process.env.API_URL || 'http://127.0.0.1:8000',
|
||||
},
|
||||
},
|
||||
|
||||
security: {
|
||||
headers: {
|
||||
crossOriginEmbedderPolicy: 'unsafe-none',
|
||||
crossOriginOpenerPolicy: 'same-origin-allow-popups',
|
||||
contentSecurityPolicy: {
|
||||
"img-src": ["'self'", "data:", "https://*", process.env.API_URL || 'http://127.0.0.1:8000'],
|
||||
},
|
||||
contentSecurityPolicy: false,
|
||||
},
|
||||
},
|
||||
|
||||
dayjs: {
|
||||
locales: ['en'],
|
||||
locales: ['en', 'de'],
|
||||
plugins: ['relativeTime', 'utc', 'timezone'],
|
||||
defaultLocale: 'en',
|
||||
defaultTimezone: 'America/New_York',
|
||||
defaultLocale: 'de',
|
||||
defaultTimezone: 'Europe/Berlin',
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -80,12 +73,12 @@ export default defineNuxtConfig({
|
|||
public: {
|
||||
apiBase: process.env.API_URL,
|
||||
apiPrefix: '/api/v1',
|
||||
storageBase: process.env.API_URL + '/storage/',
|
||||
storageBase: `${process.env.API_URL}/storage/`,
|
||||
providers: {
|
||||
google: {
|
||||
name: "Google",
|
||||
icon: "",
|
||||
color: "gray",
|
||||
name: 'Google',
|
||||
icon: '',
|
||||
color: 'gray',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ export default defineAppConfig({
|
|||
primary: 'sky',
|
||||
gray: 'cool',
|
||||
container: {
|
||||
constrained: 'max-w-7xl w-full'
|
||||
constrained: 'max-w-full w-full',
|
||||
},
|
||||
avatar: {
|
||||
default: {
|
||||
icon: 'i-heroicons-user',
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
const links = [
|
||||
{
|
||||
label: 'Company',
|
||||
icon: 'i-heroicons-building-office-2',
|
||||
label: 'Bücher',
|
||||
icon: 'i-heroicons-book-open',
|
||||
children: [
|
||||
{
|
||||
label: 'Overview',
|
||||
to: '/login',
|
||||
label: 'Übersicht',
|
||||
to: '/book-recommendations',
|
||||
icon: 'i-heroicons-eye',
|
||||
},
|
||||
{
|
||||
label: 'Add Company',
|
||||
to: '/login',
|
||||
icon: 'i-heroicons-plus-circle',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
<script setup lang="ts">
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
import DeleteBookRecommendation from '~/components/modal/DeleteBookRecommendation.vue'
|
||||
import EditBookRecommendation from '~/components/modal/EditBookRecommendation.vue'
|
||||
import NewBookRecommendation from '~/components/modal/NewBookRecommendation.vue'
|
||||
import CastVote from '~/components/modal/CastVote.vue'
|
||||
|
||||
const dayjs = useDayjs()
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
bookRecommendationStore.fetchRecommendations()
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'book_name',
|
||||
label: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
label: 'Autor',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Beschreibung',
|
||||
},
|
||||
{
|
||||
key: 'pages',
|
||||
label: 'Seiten',
|
||||
},
|
||||
{
|
||||
key: 'recommender.name',
|
||||
label: 'Empfohlen von',
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Erstellt am',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
},
|
||||
{
|
||||
key: 'votes',
|
||||
label: 'Votes',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NewBookRecommendation />
|
||||
<UTable :loading="bookRecommendationStore.fetchRecommendationsStatus === 'pending'" :columns="columns" :rows="bookRecommendationStore.recommendations">
|
||||
<template #created_at-data="{ row }">
|
||||
<div>{{ dayjs(row.created_at).format('DD.MM.YYYY') }}</div>
|
||||
</template>
|
||||
<template #description-data="{ row }">
|
||||
{{ `${row.description.substring(0, 50)}...` }}
|
||||
</template>
|
||||
<template #votes-data="{ row }">
|
||||
{{ row.votes.length }}
|
||||
</template>
|
||||
<template #actions-data="{ row }">
|
||||
<div class="flex space-x-2">
|
||||
<CastVote :row="row" />
|
||||
<EditBookRecommendation :row="row" />
|
||||
<DeleteBookRecommendation :row="row" />
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<script setup lang="ts">
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
|
||||
const props = defineProps<{
|
||||
row: {
|
||||
id: number
|
||||
book_name: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const state = reactive({
|
||||
book_recommendation_id: props.row.id,
|
||||
})
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onVote, status } = useFetch<any>(`vote`, {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Abstimmung erfolgreich.',
|
||||
color: 'emerald',
|
||||
})
|
||||
await bookRecommendationStore.fetchRecommendations()
|
||||
await authStore.fetchUser()
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton icon="i-heroicons-star" size="sm" color="green" variant="solid" square :disabled="authStore.user.total_votes === 0" @click="isOpen = true" />
|
||||
<UDashboardModal
|
||||
v-model="isOpen"
|
||||
title="Buch Empfehlung löschen"
|
||||
:description="`Bist du dir sicher das du für die Buchempfehlung ${row.book_name} abstimmen möchtest?`"
|
||||
icon="i-heroicons-star"
|
||||
:ui="{
|
||||
icon: { base: 'text-green-500 dark:text-green-400' } as any,
|
||||
footer: { base: 'ml-16' } as any,
|
||||
}"
|
||||
>
|
||||
<template #footer>
|
||||
<UButton color="primary" label="Abstimmung" :loading="status === 'pending'" @click="onVote" />
|
||||
<UButton color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
</template>
|
||||
</UDashboardModal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<script setup lang="ts">
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
|
||||
const props = defineProps<{
|
||||
row: {
|
||||
id: number
|
||||
book_name: string
|
||||
}
|
||||
}>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onDelete, status } = useFetch<any>(`book-recommendations/${props.row.id}`, {
|
||||
method: 'DELETE',
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Buchempfehlung wurde gelöscht.',
|
||||
color: 'emerald',
|
||||
})
|
||||
await bookRecommendationStore.fetchRecommendations()
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton icon="i-heroicons-trash" size="sm" color="red" variant="solid" square @click="isOpen = true" />
|
||||
<UDashboardModal
|
||||
v-model="isOpen"
|
||||
title="Buch Empfehlung löschen"
|
||||
:description="`Möchtest du die Buchempfehlung ${row.book_name} wirklich löschen?`"
|
||||
icon="i-heroicons-exclamation-circle"
|
||||
:ui="{
|
||||
icon: { base: 'text-red-500 dark:text-red-400' } as any,
|
||||
footer: { base: 'ml-16' } as any,
|
||||
}"
|
||||
>
|
||||
<template #footer>
|
||||
<UButton color="red" label="Löschen" :loading="status === 'pending'" @click="onDelete" />
|
||||
<UButton color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
</template>
|
||||
</UDashboardModal>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
<script setup lang="ts">
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
|
||||
const props = defineProps<{
|
||||
row: {
|
||||
id: number
|
||||
book_name: string
|
||||
author: string
|
||||
description: string
|
||||
isbn: string
|
||||
pages: number
|
||||
cover_image?: string
|
||||
status: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const form = ref()
|
||||
|
||||
const state = reactive({
|
||||
book_name: props.row.book_name,
|
||||
author: props.row.author,
|
||||
description: props.row.description,
|
||||
isbn: props.row.isbn,
|
||||
pages: props.row.pages,
|
||||
cover_image: props.row.cover_image,
|
||||
status: props.row.status,
|
||||
})
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations/${props.row.id}`, {
|
||||
method: 'PUT',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Buchempfehlung wurde erfolgreich aktualisiert.',
|
||||
color: 'emerald',
|
||||
})
|
||||
await bookRecommendationStore.fetchRecommendations()
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton icon="i-heroicons-pencil-square" size="sm" variant="solid" square @click="isOpen = true" />
|
||||
|
||||
<UModal v-model="isOpen">
|
||||
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Bearbeiten
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormGroup label="Name" name="book_name">
|
||||
<UInput v-model="state.book_name" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Autor" name="author">
|
||||
<UInput v-model="state.author" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Beschreibung" name="description">
|
||||
<UTextarea v-model="state.description" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="ISBN" name="isbn">
|
||||
<UInput v-model="state.isbn" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Seiten" name="pages">
|
||||
<UInput v-model="state.pages" type="number" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Status" name="status">
|
||||
<USelect v-model="state.status" :options="bookRecommendationStore.statusOptions" option-attribute="name" />
|
||||
</UFormGroup>
|
||||
<UButton size="md" type="submit" :loading="status === 'pending'">
|
||||
Speichern
|
||||
</UButton>
|
||||
<UButton size="md" class="mx-4" color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<script setup lang="ts">
|
||||
import { useBookRecommendationStore } from '~/stores/book-recommendations'
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const form = ref()
|
||||
|
||||
const state = reactive({
|
||||
book_name: null,
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
pages: null,
|
||||
cover_image: null,
|
||||
status: null,
|
||||
})
|
||||
|
||||
const bookRecommendationStore = useBookRecommendationStore()
|
||||
|
||||
const { refresh: onSubmit, status } = useFetch<any>(`book-recommendations`, {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Buchempfehlung wurde erfolgreich aktualisiert.',
|
||||
color: 'emerald',
|
||||
})
|
||||
await bookRecommendationStore.fetchRecommendations()
|
||||
|
||||
state.book_name = null
|
||||
state.author = null
|
||||
state.description = null
|
||||
state.isbn = null
|
||||
state.pages = null
|
||||
state.cover_image = null
|
||||
state.status = null
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-end">
|
||||
<UButton icon="i-heroicons-plus" label="Neues Buch" size="sm" variant="solid" color="green" square @click="isOpen = true" />
|
||||
</div>
|
||||
|
||||
<UModal v-model="isOpen">
|
||||
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Neues Buch
|
||||
</h3>
|
||||
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormGroup label="Name" name="book_name">
|
||||
<UInput v-model="state.book_name" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Autor" name="author">
|
||||
<UInput v-model="state.author" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Beschreibung" name="description">
|
||||
<UTextarea v-model="state.description" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="ISBN" name="isbn">
|
||||
<UInput v-model="state.isbn" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Seiten" name="pages">
|
||||
<UInput v-model="state.pages" type="number" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Status" name="status">
|
||||
<USelect v-model="state.status" :options="bookRecommendationStore.statusOptions" option-attribute="name" />
|
||||
</UFormGroup>
|
||||
<UButton size="md" type="submit" :loading="status === 'pending'">
|
||||
Erstellen
|
||||
</UButton>
|
||||
<UButton size="md" class="mx-4" color="white" label="Abbrechen" @click="isOpen = false" />
|
||||
</UForm>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import BookRecommendationTable from '~/components/book-recommendations/BookRecommendationTable.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BookRecommendationTable />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
@ -1,26 +1,27 @@
|
|||
import { defineStore } from 'pinia'
|
||||
|
||||
export type User = {
|
||||
ulid: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
must_verify_email: boolean;
|
||||
has_password: boolean;
|
||||
roles: string[];
|
||||
providers: string[];
|
||||
export interface User {
|
||||
ulid: string
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
must_verify_email: boolean
|
||||
has_password: boolean
|
||||
roles: string[]
|
||||
providers: string[]
|
||||
total_votes: number
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const config = useRuntimeConfig()
|
||||
const nuxtApp = useNuxtApp()
|
||||
|
||||
const user = ref(<User>{});
|
||||
const user = ref(<User>{})
|
||||
const token = useCookie('token', {
|
||||
path: '/',
|
||||
sameSite: 'strict',
|
||||
secure: config.public.apiBase.startsWith('https://'),
|
||||
maxAge: 60 * 60 * 24 * 365
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
})
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
return navigateTo('/')
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { refresh: fetchUser } = useFetch<any>('user', {
|
||||
|
|
@ -45,7 +46,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||
if (response.status === 200) {
|
||||
user.value = response._data.user
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return { user, isLoggedIn, logout, fetchUser, token }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { defineStore } from 'pinia'
|
||||
|
||||
enum BookRecommendationStatusEnum {
|
||||
PENDING = 'PENDING',
|
||||
REJECTED = 'REJECTED',
|
||||
ACTIVE = 'ACTIVE',
|
||||
COMPLETED = 'COMPLETED',
|
||||
}
|
||||
|
||||
export interface BookRecommendation {
|
||||
id: number
|
||||
book_name: string
|
||||
author: string
|
||||
description: string
|
||||
isbn: string
|
||||
pages: number
|
||||
recommended_by?: number
|
||||
recommender?: {
|
||||
ulid: number
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
status: BookRecommendationStatusEnum
|
||||
cover_image?: string
|
||||
}
|
||||
|
||||
export const useBookRecommendationStore = defineStore('bookRecommendations', () => {
|
||||
const recommendations = ref<BookRecommendation[]>([])
|
||||
|
||||
const statusOptions = [
|
||||
{
|
||||
name: 'Pending',
|
||||
value: BookRecommendationStatusEnum.PENDING,
|
||||
},
|
||||
{
|
||||
name: 'Rejected',
|
||||
value: BookRecommendationStatusEnum.REJECTED,
|
||||
},
|
||||
{
|
||||
name: 'Active',
|
||||
value: BookRecommendationStatusEnum.ACTIVE,
|
||||
},
|
||||
{
|
||||
name: 'Completed',
|
||||
value: BookRecommendationStatusEnum.COMPLETED,
|
||||
},
|
||||
]
|
||||
|
||||
// Fetch all book recommendations
|
||||
const { refresh: fetchRecommendations, status: fetchRecommendationsStatus } = useFetch<BookRecommendation[]>('book-recommendations?with=recommender,votes', {
|
||||
immediate: false,
|
||||
onResponse({ response }) {
|
||||
if (response.status === 200) {
|
||||
recommendations.value = response._data
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteRecommendation = async (id: number) => {
|
||||
try {
|
||||
const { error } = await useFetch(`book-recommendations/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (error.value) {
|
||||
console.error('Failed to delete book recommendation:', error.value)
|
||||
}
|
||||
else {
|
||||
recommendations.value = recommendations.value.filter(rec => rec.id !== id)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('An error occurred while deleting a book recommendation:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recommendations,
|
||||
statusOptions,
|
||||
fetchRecommendations,
|
||||
fetchRecommendationsStatus,
|
||||
deleteRecommendation,
|
||||
}
|
||||
})
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"api": "php artisan octane:start --watch --port=8000 --host=127.0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^2.8.3",
|
||||
"@iconify-json/heroicons": "^1.1.20",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@nuxt/devtools": "^1.0.8",
|
||||
|
|
@ -22,6 +23,8 @@
|
|||
"chokidar": "^3.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"dayjs-nuxt": "^2.1.9",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-tailwindcss": "^3.15.1",
|
||||
"nuxt": "^3.11.0",
|
||||
"nuxt-security": "^1.2.2",
|
||||
"vue": "3.4.21",
|
||||
|
|
@ -30,4 +33,4 @@
|
|||
"resolutions": {
|
||||
"vue": "3.4.21"
|
||||
}
|
||||
}
|
||||
}
|
||||
1631
pnpm-lock.yaml
1631
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
use App\Http\Controllers\AccountController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\BookRecommendationController;
|
||||
use App\Http\Controllers\UploadController;
|
||||
use App\Http\Controllers\VoteController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
|
|
@ -25,6 +27,9 @@ Route::prefix('api/v1')->group(function () {
|
|||
Route::get('devices', [AuthController::class, 'devices'])->name('devices');
|
||||
Route::get('user', [AuthController::class, 'user'])->name('user');
|
||||
|
||||
Route::resource('book-recommendations', BookRecommendationController::class);
|
||||
Route::post('/vote', [VoteController::class, 'castVote']);
|
||||
|
||||
Route::post('account/update', [AccountController::class, 'update'])->name('account.update');
|
||||
Route::post('account/password', [AccountController::class, 'password'])->name('account.password');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue