feat: Multiple Systems

main
Flycro 2024-03-18 01:26:54 +01:00
parent 22ea1930c4
commit 12d8f3913c
29 changed files with 2556 additions and 90 deletions

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();

26
app/Models/Vote.php Normal file
View File

@ -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);
}
}

View File

@ -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": {

63
composer.lock generated
View File

@ -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",

View File

@ -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();
});

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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
]);
}
}
}

View File

@ -35,5 +35,7 @@ class DatabaseSeeder extends Seeder
$user->save(['timestamps' => false]);
$user->assignRole($role1);
$this->call(BookRecommendationsTableSeeder::class);
}
}

View File

@ -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:

17
eslint.config.js Normal file
View File

@ -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',
},
}))

View File

@ -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',
},
},
},

View File

@ -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',
}
}
}
},
},
},
})

View File

@ -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',
},
],
},
{

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 }

View File

@ -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,
}
})

View File

@ -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"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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');