Compare commits

...

8 Commits

Author SHA1 Message Date
krzysiej
c054f791b1 Passing logged-in user credentials from twig to vue. 2022-06-21 12:39:22 +02:00
krzysiej
00bb86f798 Added login to the application to make authenticated api requests. 2022-06-20 15:05:27 +02:00
krzysiej
4536195a4c Removed event bus and switched to vuex. Implemented vue dropzone. 2022-06-15 15:19:46 +02:00
krzysiej
5350c5c46b Migrated progress bar and progress editor to vuex. 2022-06-14 15:19:21 +02:00
krzysiej
8dbd63478d Added vuex as a store and an event bus. 2022-06-13 15:17:02 +02:00
krzysiej
5c154b740c Added vue book search. 2022-06-13 11:44:50 +02:00
krzysiej
040ca01158 added mbstring as requirement to composer 2022-06-10 15:27:08 +02:00
krzysiej
140d68c3a9 Moved book listing to the vue component. 2022-06-10 15:26:23 +02:00
39 changed files with 763 additions and 190 deletions

View File

@@ -1,42 +0,0 @@
// filedropzone_controller.js
import { Controller } from '@hotwired/stimulus';
import axios from 'axios';
export default class extends Controller {
connect() {
this.element.addEventListener('dropzone:connect', this._onConnect);
this.element.addEventListener('dropzone:change', this._onChange);
this.element.addEventListener('dropzone:clear', this._onClear);
}
disconnect() {
// You should always remove listeners when the controller is disconnected to avoid side-effects
this.element.removeEventListener('dropzone:connect', this._onConnect);
this.element.removeEventListener('dropzone:change', this._onChange);
this.element.removeEventListener('dropzone:clear', this._onClear);
}
_onConnect(event) {
// The dropzone was just created
console.info('onconnect');
}
_onChange(event) {
// The dropzone just changed
console.info('onchange');
axios.post(document.URL, new FormData(event.target.closest('form')), {
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(() => {
event.target.querySelector('.dropzone-preview-button').click();
window.EventBus.$emit('fileUploaded');
})
}
_onClear(event) {
// The dropzone has just been cleared
console.info('onclear');
}
}

21
assets/js/api/book.js Normal file
View File

@@ -0,0 +1,21 @@
import axios from 'axios';
export default {
booksFetch(searchTitle) {
return axios.get('/api/books', {
params: {title: searchTitle},
headers: {'accept': 'application/json'}
})
},
bookUpdateProgress(bookId, progress) {
return axios.post('/progress/update', {
bookId: bookId,
progress: progress
}, {
headers: {
'content-type': 'text/json'
}
})
}
}

20
assets/js/api/file.js Normal file
View File

@@ -0,0 +1,20 @@
import axios from 'axios';
export default {
deleteFile: function (fileId) {
return axios.get(window.location.origin + '/file/delete/' + fileId);
},
getFiles: function (bookId) {
return axios.get(this.getFilesEndpoint(bookId), {
headers: {
'accept': 'application/json'
}
});
},
getFilesEndpoint: function (bookId) {
if (bookId) {
return `/api/books/${bookId}/files`;
}
return `/api/files`;
}
}

13
assets/js/api/user.js Normal file
View File

@@ -0,0 +1,13 @@
import axios from 'axios';
export default {
login: function (email, password) {
return axios.post('/login_api', {
email: email,
password: password
});
},
getUserData: function (userIri) {
return axios.get(userIri);
},
}

View File

@@ -1,6 +1,11 @@
import Vue from 'vue';
import Book from './pages/book'
import BookListing from './pages/booklisting'
import BookListingHeader from './pages/booklistingheader'
import store from "./store/index";
Vue.component('Book', Book);
Vue.component('BookListing', BookListing);
Vue.component('BookListingHeader', BookListingHeader);
new Vue().$mount('#app');
new Vue({store}).$mount('#app');

View File

@@ -0,0 +1,47 @@
<template>
<form v-on:submit.prevent="handleSubmit">
<div v-if="error" class="alert alert-danger">
{{ error }}
</div>
<div class="form-group">
<label for="exampleInputEmail1">Email address</label>
<input type="email" v-model="email" class="form-control" id="exampleInputEmail1"
aria-describedby="emailHelp" placeholder="Enter email">
</div>
<div class="form-group">
<label for="exampleInputPassword1">Password</label>
<input type="password" v-model="password" class="form-control"
id="exampleInputPassword1" placeholder="Password">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="exampleCheck1">
<label class="form-check-label" for="exampleCheck1">I like cheese</label>
</div>
<button type="submit" class="btn btn-primary" v-bind:class="{ disabled: isLoading }">Log in</button>
</form>
</template>
<script>
import {mapActions} from "vuex";
export default {
data() {
return {
email: '',
password: '',
error: '',
isLoading: false
}
},
props: ['user'],
methods: {
...mapActions('usermodule', ['login']),
handleSubmit() {
this.login({'email': this.email, 'password': this.password});
},
},
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,3 +0,0 @@
import Vue from 'vue';
window.EventBus = new Vue();
export const EventBus = window.EventBus;

View File

@@ -2,8 +2,9 @@ import Vue from 'vue';
import Files from "./pages/files";
import Progressbar from "./pages/progressbar";
import Progresseditor from "./pages/progresseditor";
import store from "./store/index";
Vue.component('Files', Files);
Vue.component('Progressbar', Progressbar);
Vue.component('Progresseditor', Progresseditor);
new Vue().$mount('#app');
new Vue({store}).$mount('#app');

View File

@@ -0,0 +1,78 @@
<template>
<div>
<input type="text" class="form-control" placeholder="search by title" v-model="searchTerm" @keydown.enter="search"/>
<table class="table">
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Description</th>
<th>Rating</th>
<th>Publisher</th>
<th>Publish date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="books.length" v-for="book in books">
<td>
<a :href="'/book/'+book.id">
<img class="img-thumbnail" style="max-width: 80px" :src="'/book_covers/cover_'+book.id+'.jpg'"/>
</a>
</td>
<td><a :href="'/book/'+book.id" class="text-decoration-none">{{ book.title }}</a></td>
<td>{{ book.short_description }}</td>
<td>{{ '⭐'.repeat(book.rating) }}</td>
<td>{{ book.publisher }}</td>
<td>{{ book.publish_date }}</td>
<td>
<a :href="'/book/'+book.id" class="text-decoration-none">show</a>
<a :href="'/book/'+book.id+'/edit/'" class="text-decoration-none">edit</a>
</td>
</tr>
<tr v-else>
<td colspan="9">no records found</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import {mapActions, mapState} from "vuex";
export default {
name: 'BookListing',
data() {
return {
searchTerm: null,
selectedBook: null,
manualMode: false
}
},
computed: {
...mapState('booksmodule', ['books']),
...mapState('usermodule', ['user']),
},
methods: {
...mapActions('booksmodule', [
'findAll'
]),
search: function () {
this.updateHistory();
this.findAll(this.searchTerm);
},
updateHistory: function () {
if (this.searchTerm && history.pushState) {
let url = window.location.protocol + "//" + window.location.host + window.location.pathname + '?search=' + this.searchTerm;
window.history.pushState({path: url}, '', url);
}
}
},
mounted() {
const urlParams = new URLSearchParams(window.location.search);
this.searchTerm = urlParams.get('search') || "";
this.search();
}
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div>
<h1 v-if="isSearching" v-html="'Book search: '+searchTitle"></h1>
<h1 v-else>Book list</h1>
</div>
</template>
<script>
import {mapGetters, mapState} from "vuex";
export default {
name: 'BookListingHeader',
data() {
return {
searchTerm: null,
}
},
computed: {
...mapState('booksmodule', ['searchTitle']),
...mapGetters('booksmodule', ['isSearching'])
},
}
</script>

View File

@@ -25,54 +25,63 @@
<td class="text-end">{{ file.file_size_human }}</td>
<td>{{ file.extension }}</td>
<td><a :href="'/file/'+ file.id" class="link-secondary">download</a></td>
<td><a :href="'/file/delete/'+file.id" @click.prevent="deleteFile(file.id)" class="link-danger">remove</a>
<td><a @click.prevent="deleteFile(file.id)" class="link-danger cursor-pointer">remove</a>
</td>
</tr>
</tbody>
</table>
</div>
<vue-dropzone v-if="bookId"
ref="myVueDropzone"
id="dropzone"
:options="dropzoneOptions"
:useCustomSlot="true"
@vdropzone-success="vdropzoneSuccess"
>
<div class="dropzone-custom-content">
<h3 class="dropzone-custom-title">Drag and drop to upload content!</h3>
<div class="subtitle">
...or click to select a file from your computer
</div>
</div>
</vue-dropzone>
</div>
</template>
<script>
import axios from 'axios';
import {EventBus} from "../event-bus";
import vue2Dropzone from "vue2-dropzone";
import {mapActions, mapState} from "vuex";
export default {
name: 'Files',
components: {
EventBus
vueDropzone: vue2Dropzone
},
data() {
return {
files: []
dropzoneOptions: {
capture: 'image/',
url: '/book/' + this.bookId,
thumbnailWidth: 150,
maxFilesize: 50.5,
acceptedFiles: '.pdf, .epub, .mobi'
}
}
},
props: {
bookId: {type: Number, default: null}
},
mounted() {
this.getFiles();
this.getFiles(this.bookId);
},
created() {
window.EventBus.$on('fileUploaded', this.getFiles);
computed: {
...mapState('filemodule', ['files'])
},
methods: {
deleteFile: function (fileId) {
axios.get(window.location.origin + '/file/delete/' + fileId).then(() => this.getFiles())
},
getFiles: function () {
axios.get(this.getFilesEndpoint(), {
headers: {
'accept': 'application/json'
}
}).then(response => this.files = response.data)
},
getFilesEndpoint: function () {
if (this.bookId) {
return `/api/books/${this.bookId}/files`;
}
return `/api/files`;
...mapActions('filemodule', ['getFiles', 'deleteFile']),
vdropzoneSuccess(file) {
this.$refs.myVueDropzone.removeFile(file);
this.getFiles(this.bookId);
}
}
}

View File

@@ -1,41 +1,31 @@
<template>
<div>
<div class="progress" style="height: 2px;">
<div class="progress-bar" role="progressbar" :style="{width:progress+'%'}"></div>
<div class="progress-bar bg-secondary bg-opacity-10" role="progressbar" :style="{width:100-progress+'%'}"></div>
<div class="progress-bar" role="progressbar" :style="{width:progressPercent+'%'}"></div>
<div class="progress-bar bg-secondary bg-opacity-10" role="progressbar"
:style="{width:100-progressPercent+'%'}"></div>
</div>
</div>
</template>
<script>
import {EventBus} from "../event-bus";
import {UPDATING_PROGRESS_SUCCESS} from '../store/mutation-types'
import {mapState} from "vuex";
export default {
name: 'Progressbar',
components: {
EventBus
},
props: {
totalPages: Number,
readPages: Number
},
data() {
return {
newProgress: this.readPages
}
},
computed: {
progress() {
return Math.round(this.newProgress / this.totalPages * 100);
...mapState('bookprogressmodule', ['progress']),
progressPercent() {
return Math.round(this.progress / this.totalPages * 100);
}
},
mounted() {
},
created() {
window.EventBus.$on('updateProgress', (data) => {
this.newProgress = data.readPages;
});
this.$store.commit('bookprogressmodule/' + UPDATING_PROGRESS_SUCCESS, this.readPages);
},
methods: {}
}
</script>

View File

@@ -1,9 +1,9 @@
<template>
<div>
<span @click="edit" v-if="!editmode">
<span @click="edit" v-if="!isEditMode">
{{ newProgress }} pages | {{ progress }}%
</span>
<span v-show="editmode">
<span v-show="isEditMode">
<input type="number" @keydown.esc="cancelEdit" @keydown.enter="submit" ref="readPagesInput" min="0"
:max="totalPages" v-model.number="newProgress"/>
</span>
@@ -11,14 +11,11 @@
</template>
<script>
import {EventBus} from "../event-bus";
import axios from "axios";
import {mapActions, mapGetters} from "vuex";
import {TURN_ON_EDIT_MODE, TURN_OFF_EDIT_MODE} from '../store/mutation-types'
export default {
name: 'Progresseditor',
components: {
EventBus
},
props: {
totalPages: Number,
readPages: Number,
@@ -26,40 +23,32 @@ export default {
},
data() {
return {
editmode: false,
newProgress: this.readPages
}
},
computed: {
progress() {
return Math.round(this.newProgress / this.totalPages * 100);
}
},
mounted() {
...mapGetters('bookprogressmodule', ['isEditMode'])
},
created() {
this.newProgress = this.readPages;
},
methods: {
...mapActions('bookprogressmodule', [
'updateProgress'
]),
edit: function () {
this.editmode = true;
this.$store.commit('bookprogressmodule/' + TURN_ON_EDIT_MODE);
setTimeout(() => this.$refs.readPagesInput.focus(), 1)
},
cancelEdit: function () {
this.editmode = false;
this.$store.commit('bookprogressmodule/' + TURN_OFF_EDIT_MODE);
},
submit: function () {
this.editmode = false;
axios.post('/progress/update', {
bookId: this.bookId,
progress: this.newProgress
}, {
headers: {
'content-type': 'text/json'
}
}).then(() => {
window.EventBus.$emit('updateProgress', {readPages: this.newProgress});
})
this.$store.commit('bookprogressmodule/' + TURN_OFF_EDIT_MODE);
this.updateProgress({'bookId': this.bookId, 'progress': this.newProgress});
}
}
}

18
assets/js/store/index.js Normal file
View File

@@ -0,0 +1,18 @@
import Vue from 'vue'
import Vuex from 'vuex'
import booksmodule from './modules/books';
import bookprogressmodule from './modules/bookprogress';
import filemodule from './modules/filemodule';
import usermodule from './modules/usermodule';
Vue.use(Vuex)
export default new Vuex.Store({
modules:{
booksmodule,
bookprogressmodule,
filemodule,
usermodule
}
})

View File

@@ -0,0 +1,70 @@
import BookApi from "../../api/book";
import {
UPDATING_PROGRESS,
UPDATING_PROGRESS_SUCCESS,
UPDATING_PROGRESS_ERROR,
TURN_ON_EDIT_MODE,
TURN_OFF_EDIT_MODE
} from '../mutation-types.js'
export default {
namespaced: true,
state: {
isUpdating: false,
error: null,
progress: null,
editMode: false
},
getters: {
isEditMode(state) {
return state.editMode;
},
isUpdating(state) {
return state.isUpdating;
},
hasError(state) {
return state.error !== null;
},
error(state) {
return state.error;
},
progress(state) {
return state.progress;
},
},
mutations: {
[UPDATING_PROGRESS](state) {
state.isUpdating = true;
state.error = null;
state.progress = null;
},
[UPDATING_PROGRESS_SUCCESS](state, progress) {
state.isLoading = false;
state.error = null;
state.progress = progress;
},
[UPDATING_PROGRESS_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.progress = null;
},
[TURN_ON_EDIT_MODE](state) {
state.editMode = true;
},
[TURN_OFF_EDIT_MODE](state) {
state.editMode = false;
}
},
actions: {
async updateProgress({commit}, data) {
commit(UPDATING_PROGRESS);
try {
let response = await BookApi.bookUpdateProgress(data.bookId, data.progress);
commit(UPDATING_PROGRESS_SUCCESS, response.data.progress);
return response.data;
} catch (error) {
commit(UPDATING_PROGRESS_ERROR, error);
}
},
}
};

View File

@@ -0,0 +1,72 @@
import BookApi from "../../api/book";
const
FETCHING_BOOKS = "FETCHING_BOOKS",
FETCHING_BOOKS_SUCCESS = "FETCHING_BOOKS_SUCCESS",
FETCHING_BOOKS_ERROR = "FETCHING_BOOKS_ERROR",
SETTING_SEARCH_TERM = "SETTING_SEARCH_TERM"
;
export default {
namespaced: true,
state: {
isLoading: false,
error: null,
books: [],
searchTitle: null
},
getters: {
isLoading(state) {
return state.isLoading;
},
hasError(state) {
return state.error !== null;
},
error(state) {
return state.error;
},
hasBooks(state) {
return state.books.length > 0;
},
books(state) {
return state.books;
},
isSearching(state) {
return !!state.searchTitle;
}
},
mutations: {
[SETTING_SEARCH_TERM](state, searchTitle) {
state.searchTitle = searchTitle;
},
[FETCHING_BOOKS](state) {
state.isLoading = true;
state.error = null;
state.books = [];
},
[FETCHING_BOOKS_SUCCESS](state, books) {
state.isLoading = false;
state.error = null;
state.books = books;
},
[FETCHING_BOOKS_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.books = [];
}
},
actions: {
async findAll({commit, state}, searchTitle) {
commit(FETCHING_BOOKS);
try {
commit(SETTING_SEARCH_TERM, searchTitle);
let response = await BookApi.booksFetch(state.searchTitle);
commit(FETCHING_BOOKS_SUCCESS, response.data);
return response.data;
} catch (error) {
commit(FETCHING_BOOKS_ERROR, error);
return null;
}
}
}
};

View File

@@ -0,0 +1,68 @@
import FileApi from "../../api/file";
import {
REMOVE_FILE_FROM_LIST,
FETCHING_FILES,
FETCHING_FILES_SUCCESS,
FETCHING_FILES_ERROR
} from '../mutation-types.js'
export default {
namespaced: true,
state: {
isLoading: false,
error: null,
files: []
},
getters: {
isLoading(state) {
return state.isLoading;
},
hasError(state) {
return state.error !== null;
},
error(state) {
return state.error;
}
},
mutations: {
[FETCHING_FILES](state) {
state.isLoading = true;
state.error = null;
},
[FETCHING_FILES_SUCCESS](state, files) {
state.isLoading = false;
state.error = null;
state.files = files;
},
[FETCHING_FILES_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.files = [];
},
[REMOVE_FILE_FROM_LIST](state, fileId) {
const index = state.files.findIndex(file => file.id === fileId);
state.files.splice(index, 1);
}
},
actions: {
async deleteFile({commit}, fileId) {
try {
await FileApi.deleteFile(fileId);
commit(REMOVE_FILE_FROM_LIST, fileId)
} catch (error) {
commit(FETCHING_FILES_ERROR, error);
}
},
async getFiles({commit}, bookId) {
commit(FETCHING_FILES);
try {
let response = await FileApi.getFiles(bookId);
commit(FETCHING_FILES_SUCCESS, response.data);
return response.data;
} catch (error) {
commit(FETCHING_FILES_ERROR, error);
}
},
}
};

View File

@@ -0,0 +1,83 @@
import UserApi from "../../api/user";
import {
LOGIN_START,
LOGIN_SUCCESS,
LOGIN_ERROR,
STORE_USER_INFO,
LOGIN_STOP,
} from '../mutation-types.js'
export default {
namespaced: true,
state: {
isLoading: false,
error: null,
user: window.user,
userUri: null,
},
getters: {
isLoading(state) {
return state.isLoading;
},
hasError(state) {
return state.error !== null;
},
error(state) {
return state.error;
}
},
mutations: {
[LOGIN_START](state) {
state.isLoading = true;
state.error = null;
state.user = null;
},
[LOGIN_SUCCESS](state, userUri) {
state.isLoading = false;
state.error = null;
state.userUri = userUri;
},
[LOGIN_STOP](state, userUri) {
state.isLoading = false;
},
[STORE_USER_INFO](state, user) {
state.isLoading = false;
state.error = null;
state.user = user;
},
[LOGIN_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.user = null;
},
},
actions: {
async login({dispatch, commit}, data) {
console.info(data.email);
console.info(data.password);
commit(LOGIN_START);
let response = await UserApi.login(data.email, data.password)
.then(response => {
console.log(response.data);
console.log(response.headers.location);
// commit(LOGIN_SUCCESS,response.data);
dispatch('getUserInfo', response.headers.location)
commit(LOGIN_SUCCESS, response.headers.location);
//this.$emit('user-authenticated', userUri);
}).catch(error => {
if (error.response.data.error) {
commit(LOGIN_ERROR, error.response.data.error);
console.log(error.response.data.error);
}
}).finally(() => {
commit(LOGIN_STOP);
})
},
async getUserInfo({commit}, userUri) {
let userInfo = await UserApi.getUserData(userUri);
commit(STORE_USER_INFO, userInfo.data);
}
}
};

View File

@@ -0,0 +1,18 @@
export const
UPDATING_PROGRESS = "UPDATING_PROGRESS",
UPDATING_PROGRESS_SUCCESS = "UPDATING_PROGRESS_SUCCESS",
UPDATING_PROGRESS_ERROR = "UPDATING_PROGRESS_ERROR",
TURN_ON_EDIT_MODE = "TURN_ON_EDIT_MODE",
TURN_OFF_EDIT_MODE = "TURN_OFF_EDIT_MODE",
FETCHING_FILES = "FETCHING_FILES",
FETCHING_FILES_SUCCESS = "FETCHING_FILES_SUCCESS",
FETCHING_FILES_ERROR = "FETCHING_FILES_ERROR",
REMOVE_FILE_FROM_LIST = "REMOVE_FILE_FROM_LIST",
LOGIN_START = "LOGIN_START",
LOGIN_SUCCESS = "LOGIN_SUCCESS",
LOGIN_ERROR = "LOGIN_ERROR",
LOGIN_STOP = "LOGIN_STOP",
STORE_USER_INFO = "STORE_USER_INFO"
;

6
assets/js/user.js Normal file
View File

@@ -0,0 +1,6 @@
import Vue from 'vue';
import Loginform from "./components/loginform";
import store from "./store/index";
Vue.component('Loginform', Loginform);
new Vue({store}).$mount('#app');

View File

@@ -1,4 +1,5 @@
@import "~bootstrap";
@import "vue2-dropzone/dist/vue2Dropzone.min.css";
body {
//background-color: lightblue !important;

View File

@@ -49,7 +49,8 @@
"symfony/yaml": "6.0.*",
"techtube/bookinfo": "1.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
"twig/twig": "^2.12|^3.0",
"ext-mbstring": "*"
},
"config": {
"allow-plugins": {

View File

@@ -14,6 +14,10 @@ security:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
json_login:
check_path: app_login_api
username_path: email
password_path: password
lazy: true
provider: app_user_provider
custom_authenticator: App\Security\CustomAuthenticator

View File

@@ -27,6 +27,8 @@
"dependencies": {
"@popperjs/core": "^2.11.5",
"bootstrap": "^5.1.3",
"popper": "^1.0.1"
"popper": "^1.0.1",
"vue2-dropzone": "^3.6.0",
"vuex": "^3"
}
}

View File

@@ -20,10 +20,15 @@ use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class BookController extends AbstractController
{
#[Route('/', name: 'app_book_index', methods: ['GET'])]
public function index(BookRepository $bookRepository): Response
public function index(BookRepository $bookRepository, Request $request): Response
{
if ($request->query->has('search')) {
$books = $bookRepository->findByTitle($request->query->get('search'));
}
return $this->render('book/index.html.twig', [
'books' => $bookRepository->findAll(),
'searchTerm' => $request->query->get('search'),
'books' => $books ?? $bookRepository->findAll(),
]);
}
@@ -98,16 +103,14 @@ class BookController extends AbstractController
): Response {
$fileForm = $this->createForm(FileType::class);
$fileForm->handleRequest($request);
if ($fileForm->isSubmitted() && $fileForm->isValid()) {
/** @var UploadedFile[] $ebooks */
$ebook = $request->files->get('file')['file'];
if ($fileForm->isSubmitted()) {
/** @var UploadedFile $ebook */
$ebook = $request->files->get('file');
$fileService->saveFile($ebook, $book);
}
return $this->renderForm('book/show.html.twig', [
return $this->render('book/show.html.twig', [
'book' => $book,
'file_form' => $fileForm
]);
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class IndexController extends AbstractController
{
#[Route('/', name: 'app_index')]
public function index(): Response
{
return $this->render('index/index.html.twig', [
'controller_name' => 'IndexController',
]);
}
}

View File

@@ -2,19 +2,22 @@
namespace App\Controller;
use ApiPlatform\Core\Api\IriConverterInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Validator\Constraints\Json;
class SecurityController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// if ($this->getUser()) {
// return $this->redirectToRoute('target_path');
// }
if ($this->getUser()) {
return $this->redirectToRoute('app_book_index');
}
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
@@ -24,9 +27,23 @@ class SecurityController extends AbstractController
return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
}
#[Route(path: '/login_api', name: 'app_login_api', methods: ['POST'])]
public function login_api(IriConverterInterface $iriConverter): Response
{
return new Response(null, 204, [
'Location' => $iriConverter->getIriFromItem($this->getUser())
]);
// return $this->json([
// 'user' => $this->getUser() ? $this->getUser()->getId() : null
// ]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
throw new \LogicException(
'This method can be blank - it will be intercepted by the logout key on your firewall.'
);
}
}

View File

@@ -11,7 +11,10 @@ use App\Repository\BookRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Context;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ApiResource]
#[ORM\Entity(repositoryClass: BookRepository::class)]
@@ -38,6 +41,7 @@ class Book
private $publisher;
#[ORM\Column(type: 'date', nullable: true)]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private $publish_date;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
@@ -116,6 +120,12 @@ class Book
return $this->description;
}
#[SerializedName('short_description')]
public function getShortDescription(): ?string
{
return mb_strimwidth($this->description, 0, 150, "...");;
}
public function setDescription(?string $description): self
{
$this->description = $description;

View File

@@ -2,11 +2,17 @@
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(collectionOperations: [
"get",
"post" => ["security" => "is_granted('ROLE_USER')"]
], normalizationContext: ['groups' => ['user:read']])]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
@@ -17,9 +23,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
#[Groups('user:read')]
private $email;
#[ORM\Column(type: 'json')]
#[Groups('user:read')]
private $roles = [];
#[ORM\Column(type: 'string')]
@@ -49,7 +57,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
return (string)$this->email;
}
/**

View File

@@ -53,9 +53,8 @@ class BookType extends AbstractType
'required' => false,
'attr' => ['accept' => ".pdf, .epub, .mobi"]
])
// ->add('cover', FileType::class, ['mapped' => false, 'data_class' => null, 'required' => false])
->add('cover_url', TextType::class, ['mapped' => false, 'help' => 'Fill in the field with a link to a cover image to use it as a cover for the book.', 'label'=> 'Cover url', 'required' => false])
->add('cover', DropzoneType::class, ['mapped' => false, 'data_class' => null, 'required' => false, 'attr' => ['accept' => "image/*", 'placeholder' => 'Drag and drop or browse']])
->add('cover', FileType::class, ['mapped' => false, 'data_class' => null, 'required' => false, 'attr' => ['accept' => "image/*"]])
;
}

View File

@@ -22,7 +22,6 @@ class FileType extends AbstractType
'data_class' => null,
'required' => true,
'attr' => [
'data-controller' => 'filedropzone',
'accept' => ".pdf, .epub, .mobi",
'placeholder' => 'Drag and drop or browse'
]

View File

@@ -49,9 +49,7 @@ class CustomAuthenticator extends AbstractLoginFormAuthenticator
return new RedirectResponse($targetPath);
}
// For example:
// return new RedirectResponse($this->urlGenerator->generate('some_route'));
throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
return new RedirectResponse($this->urlGenerator->generate('app_book_index'));
}
protected function getLoginUrl(Request $request): string

View File

@@ -5,6 +5,8 @@ namespace App\Twig;
use App\Form\SearchType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -12,11 +14,18 @@ use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
private $formFactory;
private FormFactoryInterface $formFactory;
private SerializerInterface $serializer;
private TokenStorageInterface $tokenStorage;
public function __construct(FormFactoryInterface $formFactory)
{
public function __construct(
FormFactoryInterface $formFactory,
SerializerInterface $serializer,
TokenStorageInterface $tokenStorage
) {
$this->formFactory = $formFactory;
$this->serializer = $serializer;
$this->tokenStorage = $tokenStorage;
}
public function getFunctions()
@@ -24,6 +33,7 @@ class AppExtension extends AbstractExtension
return [
new TwigFunction('file_exists', [$this, 'file_exists']),
new TwigFunction('render_search_form', [$this, 'render_search_form']),
new TwigFunction('get_user', [$this, 'get_user']),
];
}
@@ -50,4 +60,9 @@ class AppExtension extends AbstractExtension
{
return $this->formFactory->create(SearchType::class)->createView();
}
public function get_user()
{
return $this->serializer->serialize($this->tokenStorage->getToken()?->getUser(), 'jsonld');
}
}

View File

@@ -5,7 +5,9 @@
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>📔</text></svg>">
<script>
window.user = {{ get_user() | raw }};
</script>
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}

View File

@@ -3,55 +3,20 @@
{% block title %}Book index{% endblock %}
{% block body %}
<div id="app">
<div class="d-flex flex-row justify-content-between align-items-center">
<div>
<h1>Book list</h1>
<book-listing-header></book-listing-header>
</div>
<div>
<a href="{{ path('app_book_new') }}" class="btn btn-primary">Add new</a>
</div>
</div>
<table class="table">
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Description</th>
<th>Rating</th>
<th>Publisher</th>
<th>Publish date</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for book in books %}
<tr>
<td>
{% if file_exists(asset('book_covers/cover_' ~ book.id ~ '.jpg')) %}
<a href="{{ path('app_book_show', {'id': book.id}) }}"><img class="img-thumbnail"
style="max-width: 80px"
src="{{ asset('book_covers/cover_' ~ book.id ~ '.jpg' ) }}"/>
</a>
{% endif %}
</td>
<td><a href="{{ path('app_book_show', {'id': book.id}) }}"
class="text-decoration-none">{{ book.title }}</a></td>
<td>{{ book.description | slice(0, 200) }}</td>
<td>{% if book.rating %}{% for i in range(1, book.rating) %}{% endfor %}{% endif %}</td>
<td>{{ book.publisher }}</td>
<td>{{ book.publishDate ? book.publishDate|date('Y-m-d') : '' }}</td>
<td>
<a href="{{ path('app_book_show', {'id': book.id}) }}" class="text-decoration-none">show</a>
<a href="{{ path('app_book_edit', {'id': book.id}) }}" class="text-decoration-none">edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="9">no records found</td>
</tr>
{% endfor %}
</tbody>
</table>
<book-listing></book-listing>
</div>
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('book') }}
{% endblock %}

View File

@@ -70,9 +70,9 @@
<files :book-id="{{ book.id }}"></files>
</div>
{{ form_start(file_form) }}
{{ form_widget(file_form) }}
{{ form_end(file_form) }}
{# {{ form_start(file_form) }}#}
{# {{ form_widget(file_form) }}#}
{# {{ form_end(file_form) }}#}
<a href="{{ path('app_book_index') }}">back to list</a>

View File

@@ -0,0 +1,27 @@
{% extends 'base.html.twig' %}
{% block title %}Hello IndexController!{% endblock %}
{% block body %}
<style>
.example-wrapper {
margin: 1em auto;
max-width: 800px;
width: 95%;
font: 18px/1.5 sans-serif;
}
.example-wrapper code {
background: #F5F5F5;
padding: 2px 6px;
}
</style>
<div id="app">
<loginform></loginform>
</div>
{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('user') }}
{% endblock %}

View File

@@ -23,6 +23,7 @@ Encore
.addEntry('app', './assets/js/app.js')
.addEntry('files', './assets/js/files.js')
.addEntry('book', './assets/js/book.js')
.addEntry('user', './assets/js/user.js')
// enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
.enableStimulusBridge('./assets/controllers.json')

View File

@@ -3188,6 +3188,11 @@ domutils@^2.5.2, domutils@^2.8.0:
domelementtype "^2.2.0"
domhandler "^4.2.0"
dropzone@^5.5.1:
version "5.9.3"
resolved "https://registry.yarnpkg.com/dropzone/-/dropzone-5.9.3.tgz#b3070ae090fa48cbc04c17535635537ca72d70d6"
integrity sha512-Azk8kD/2/nJIuVPK+zQ9sjKMRIpRvNyqn9XwbBHNq+iNuSccbJS6hwm1Woy0pMST0erSo0u4j+KJaodndDk4vA==
duplexer2@^0.1.2, duplexer2@~0.1.0, duplexer2@~0.1.2:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@@ -7426,11 +7431,23 @@ vue-template-es2015-compiler@^1.9.0:
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
vue2-dropzone@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/vue2-dropzone/-/vue2-dropzone-3.6.0.tgz#b4bb4b64de1cbbb3b88f04b24878e06780a51546"
integrity sha512-YXC1nCWIZvfa98e/i6h+EshZCkFSxFEh0Sxr9ODfThAPPDVhAzLLlz/4XIx0NGO1QeSy6htwSstte47R7vVhLQ==
dependencies:
dropzone "^5.5.1"
vue@^2.5:
version "2.6.14"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"
integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==
vuex@^3:
version "3.6.2"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71"
integrity sha512-ETW44IqCgBpVomy520DT5jf8n0zoCac+sxWnn+hMe/CzaSejb/eVw2YToiXYX+Ex/AuHHia28vWTq4goAexFbw==
watchpack@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"