Reworked create page to add location, added restaurants store.

main
Eloi Zalczer 2024-06-14 18:03:16 +02:00
parent 0602a96af5
commit ad56794b95
15 changed files with 286 additions and 157 deletions

6
package-lock.json generated
View File

@ -18,6 +18,7 @@
"datatables.net-select": "^2.0.3",
"datatables.net-vue3": "^3.0.1",
"leaflet": "^1.9.4",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"pocketbase": "^0.21.3",
"prettier-plugin-tailwindcss": "^0.6.2",
@ -1637,6 +1638,11 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lru-cache": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz",

View File

@ -19,6 +19,7 @@
"datatables.net-select": "^2.0.3",
"datatables.net-vue3": "^3.0.1",
"leaflet": "^1.9.4",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"pocketbase": "^0.21.3",
"prettier-plugin-tailwindcss": "^0.6.2",

View File

@ -2,6 +2,9 @@
import { RouterView } from "vue-router";
import "./style.css";
import NavigationBar from "./components/NavigationBar.vue";
import { useRestaurantsStore } from "./stores/restaurants";
useRestaurantsStore().init();
</script>
<template>

View File

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import ModalComponent from "./ModalComponent.vue";
import { toTypedSchema } from "@vee-validate/yup";
import { useForm } from "vee-validate";
import * as yup from "yup";
import { pb } from "../pocketbase";
import TagsField from "./TagsField.vue";
import LatLng from "../models/map";
import Tag from "../models/tag";
const modal = ref<typeof ModalComponent>();
const { errors, handleSubmit, defineField, resetForm } = useForm({
validationSchema: toTypedSchema(
yup.object({
name: yup.string().required("This field is required."),
tags: yup.array().of(yup.string()),
price: yup.string().required("This field is required."),
})
),
});
const submit = handleSubmit((values) => {
const tags: string[] = [];
values.tags?.forEach((name) => {
const tag = availableTags.value.filter((tag) => tag.name === name)[0];
if (tag !== undefined) {
tags.push(tag.id);
}
});
try {
pb.collection("restaurants").create({
name: values.name,
price_range: values.price,
tags: tags,
latitude: position.value?.lat,
longitude: position.value?.lng,
});
emit("succeeded", `Restaurant ${values.name} created successfully!`);
} catch (err) {
emit("failed", err);
}
resetForm();
});
const availableTags = ref<Tag[]>([]);
const tagsNames = computed(() => {
return availableTags.value.map((tag) => tag.name);
});
onMounted(async () => {
availableTags.value = await pb.collection("tags").getFullList();
pb.collection("tags").subscribe("*", async (e) => {
availableTags.value = await pb.collection("tags").getFullList();
});
});
const [name, nameAttrs] = defineField("name");
const [tags, tagsAttrs] = defineField("tags");
const [price, priceAttrs] = defineField("price");
const emit = defineEmits({
succeeded: (message: string) => true,
failed: (message: string) => true,
rejected: () => true,
});
function show() {
modal.value?.open();
}
function hide() {
modal.value?.close();
}
async function onAccepted() {
await submit();
}
function onRejected() {
resetForm();
emit("rejected");
}
const position = ref<LatLng>();
function setRestaurantPosition(latlng: LatLng) {
position.value = latlng;
}
defineExpose({ show, hide, setRestaurantPosition });
</script>
<template>
<ModalComponent
id="create_restaurant_modal"
ref="modal"
title="Create restaurant"
confirm-button-text="Create"
reject-button-text="Cancel"
@accepted="onAccepted"
@rejected="onRejected"
>
<div class="flex flex-col place-content-center items-center h-full">
<div class="flex flex-row flex-nowrap w-96">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Name </label>
<input
:class="errors.name === undefined ? 'form-field' : 'form-field-error'"
v-model="name"
v-bind="nameAttrs"
type="text"
/>
<label class="text-red-500">{{ errors.name }}</label>
</div>
</div>
<div class="flex flex-row flex-nowrap w-96">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Tags </label>
<TagsField v-model="tags" v-bind="tagsAttrs" :options="tagsNames" />
<br />
<label class="text-red-500">{{ errors.tags }}</label>
</div>
</div>
<div class="flex flex-row flex-nowrap w-96">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Price </label>
<select
:class="errors.price === undefined ? 'form-select' : 'form-select-error'"
v-model="price"
v-bind="priceAttrs"
>
<option value="€">1-10</option>
<option value="€€">10-20</option>
<option value="€€€">20-30</option>
</select>
<label class="text-red-500">{{ errors.price }}</label>
</div>
</div>
</div>
</ModalComponent>
</template>

View File

@ -3,7 +3,7 @@ import { ref } from "vue";
import ModalComponent from "./ModalComponent.vue";
import { toTypedSchema } from "@vee-validate/yup";
import { Form, Field, ErrorMessage, useForm } from "vee-validate";
import { useForm } from "vee-validate";
import * as yup from "yup";
import { pb } from "../pocketbase";

View File

@ -42,7 +42,7 @@ defineExpose({ open, close });
<template>
<div :class="'modal overflow-y-auto ' + (opened ? 'modal-open' : '')" :id="`modal-${props.id}`">
<div :class="'modal-box ' + style">
<div :class="'modal-box overflow-y-visible' + style">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" @click="reject"></button>
</form>
@ -54,7 +54,7 @@ defineExpose({ open, close });
<button class="btn btn-outline btn-success" @click="accept">
{{ props.confirmButtonText }}
</button>
<button v-if="rejectButtonText !== undefined" class="btn btn-outline btn-success" @click="reject">
<button v-if="rejectButtonText !== undefined" class="ml-3 btn btn-outline btn-success" @click="reject">
{{ props.rejectButtonText }}
</button>
</div>

View File

@ -42,7 +42,7 @@ import { currentUser, signOut } from "../pocketbase";
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
<font-awesome-icon icon="fa-solid fa-user" />
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-40">
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[20000] p-2 shadow bg-base-100 rounded-box w-40">
<li v-if="currentUser" class="mb-2">Logged in as {{ currentUser.username }}</li>
<li v-if="currentUser" @click="signOut" class="text-red-600 btn btn-xs">Sign Out</li>
<li v-else>

View File

@ -3,11 +3,16 @@ import { ref, onMounted, PropType, watch } from "vue";
import "leaflet/dist/leaflet.css";
import * as L from "leaflet";
import "../assets/map.css";
import LatLng from "../models/map";
import { storeToRefs } from "pinia";
import { useRestaurantsStore } from "../stores/restaurants";
const initialMap = ref(null);
const initialMap = ref<L.Map | null>(null);
const props = defineProps({
restaurants: { type: Array as PropType<Object[]> },
const { restaurants } = storeToRefs(useRestaurantsStore());
const emit = defineEmits({
clicked: (latlng: LatLng) => true,
});
onMounted(() => {
@ -17,15 +22,17 @@ onMounted(() => {
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(initialMap.value);
initialMap.value.on("click", function (e) {
console.log(e.latlng);
initialMap.value!.on("click", function (e) {
emit("clicked", e.latlng);
});
watch(
() => props.restaurants,
() => restaurants.value,
async (newval, oldval) => {
props.restaurants?.forEach((restaurant) => {
new L.marker({ lat: 22.353953049824614, lng: 86.0591123082797 }).addTo(initialMap);
restaurants.value?.forEach((restaurant) => {
const marker = L.marker({ lat: restaurant.latitude, lng: restaurant.longitude });
marker.bindPopup(`<p>${restaurant.name}</p>`);
marker.addTo(initialMap.value);
});
},
{ immediate: true }
@ -35,6 +42,6 @@ onMounted(() => {
<template>
<div>
<div @click="onClick" id="map"></div>
<div id="map"></div>
</div>
</template>

View File

@ -0,0 +1,4 @@
export default interface LatLng {
lat: number;
lng: number;
}

View File

@ -0,0 +1,8 @@
export default interface Restaurant {
name: string;
tags: string[];
price: string;
averageRating: number;
latitude: number;
longitude: number;
}

View File

@ -0,0 +1,4 @@
export default interface Tag {
id: string;
name: string;
}

View File

@ -0,0 +1,55 @@
import { defineStore } from "pinia";
import { pb } from "../pocketbase";
import { average } from "../utils/math";
import Restaurant from "../models/restaurant";
import _ from "lodash";
export const useRestaurantsStore = defineStore("restaurants", {
state: () => ({
restaurants: [] as Restaurant[],
}),
getters: {
getRandomRestaurant(state) {
return () => _.sample(state.restaurants);
},
},
actions: {
async load() {
const restaurants = await pb.collection("restaurants").getFullList({ expand: "tags,reviews_via_restaurant" });
restaurants.forEach((restaurant) => {
const ratings: number[] = [];
restaurant.expand?.reviews_via_restaurant?.forEach((review) => {
ratings.push(review.rating);
});
if (ratings.length !== 0) {
restaurant.average_rating = average(ratings);
} else {
restaurant.average_rating = null;
}
if (restaurant.expand?.tags !== undefined) {
restaurant.tags = restaurant.expand.tags.map((tag) => tag.name);
} else {
restaurant.tags = [];
}
});
this.restaurants = restaurants;
},
async init() {
await this.load();
pb.collection("restaurants").subscribe("*", async (e) => {
this.load();
});
pb.collection("reviews").subscribe("*", async (e) => {
this.load();
});
},
},
});

View File

@ -1,69 +1,31 @@
<script setup lang="ts">
import { useForm } from "vee-validate";
import { toTypedSchema } from "@vee-validate/yup";
import * as yup from "yup";
import "@/assets/form.css";
import { pb } from "../pocketbase.ts";
import TagsField from "../components/TagsField.vue";
import { computed, onMounted, ref } from "vue";
const { meta, errors, handleSubmit, defineField, resetForm } = useForm({
validationSchema: toTypedSchema(
yup.object({
name: yup.string().required("This field is required."),
tags: yup.array().of(yup.string()).required("This field is required."),
price: yup.string().required("This field is required."),
})
),
});
const onSubmit = handleSubmit((values) => {
const tags: Object[] = [];
values.tags.forEach((name) => {
const tag = availableTags.value.filter((tag) => tag.name === name)[0];
if (tag !== undefined) {
tags.push(tag.id);
}
});
try {
pb.collection("restaurants").create({
name: values.name,
price_range: values.price,
tags: tags,
});
successMessage.value = `Restaurant ${values.name} created successfully!`;
errorMessage.value = undefined;
} catch (err) {
errorMessage.value = err;
successMessage.value = undefined;
}
resetForm();
});
import RestaurantsMap from "../components/RestaurantsMap.vue";
import { ref } from "vue";
import CreateRestaurantModal from "../components/CreateRestaurantModal.vue";
const errorMessage = ref<string>();
const successMessage = ref<string>();
const availableTags = ref<Object[]>([]);
const createRestaurantModal = ref<typeof CreateRestaurantModal>();
const tagsNames = computed(() => {
return availableTags.value.map((tag) => tag.name);
});
function onMapClicked(latlng) {
createRestaurantModal.value?.setRestaurantPosition(latlng);
createRestaurantModal.value?.show();
}
const [name, nameAttrs] = defineField("name");
const [tags, tagsAttrs] = defineField("tags");
const [price, priceAttrs] = defineField("price");
function onRestaurantCreationSucceeded(message: string) {
successMessage.value = message;
errorMessage.value = undefined;
createRestaurantModal.value?.hide();
}
onMounted(async () => {
availableTags.value = await pb.collection("tags").getFullList();
pb.collection("tags").subscribe("*", async (e) => {
availableTags.value = await pb.collection("tags").getFullList();
});
});
function onRestaurantCreationFailed(message: string) {
successMessage.value = undefined;
errorMessage.value = message;
createRestaurantModal.value?.hide();
}
</script>
<template>
@ -76,42 +38,11 @@ onMounted(async () => {
</div>
</div>
<form class="flex flex-col place-content-center items-center h-full" @submit.prevent="onSubmit">
<div class="flex flex-row flex-nowrap w-96">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Name </label>
<input
:class="errors.name === undefined ? 'form-field' : 'form-field-error'"
v-model="name"
v-bind="nameAttrs"
type="text"
<RestaurantsMap @clicked="onMapClicked" />
<CreateRestaurantModal
@succeeded="onRestaurantCreationSucceeded"
@failed="onRestaurantCreationFailed"
ref="createRestaurantModal"
/>
<label class="text-red-500">{{ errors.name }}</label>
</div>
</div>
<div class="flex flex-row flex-nowrap w-96">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Tags </label>
<TagsField v-model="tags" v-bind="tagsAttrs" :options="tagsNames" />
<br />
<label class="text-red-500">{{ errors.tags }}</label>
</div>
</div>
<div class="flex flex-row flex-nowrap w-96">
<div class="mx-5 my-4 w-full">
<label class="form-label"> Price </label>
<select
:class="errors.price === undefined ? 'form-select' : 'form-select-error'"
v-model="price"
v-bind="priceAttrs"
>
<option value="€">1-10</option>
<option value="€€">10-20</option>
<option value="€€€">20-30</option>
</select>
<label class="text-red-500">{{ errors.price }}</label>
</div>
</div>
<button class="btn btn-outline">Create</button>
</form>
</template>

View File

@ -5,6 +5,8 @@ import RestaurantsTable from "../components/RestaurantsTable.vue";
import RestaurantsMap from "../components/RestaurantsMap.vue";
import ReviewsDrawer from "../components/ReviewsDrawer.vue";
import { average } from "../utils/math";
import { storeToRefs } from "pinia";
import { useRestaurantsStore } from "../stores/restaurants";
const data = ref<Object[]>([]);
@ -13,42 +15,7 @@ const table = ref<typeof RestaurantsTable>();
const selectedRestaurant = ref<Object>();
async function load() {
const restaurants = await pb.collection("restaurants").getFullList({ expand: "tags,reviews_via_restaurant" });
restaurants.forEach((restaurant) => {
const ratings: number[] = [];
restaurant.expand?.reviews_via_restaurant?.forEach((review) => {
ratings.push(review.rating);
});
if (ratings.length !== 0) {
restaurant.average_rating = average(ratings);
} else {
restaurant.average_rating = null;
}
if (restaurant.expand?.tags !== undefined) {
restaurant.tags = restaurant.expand.tags.map((tag) => tag.name);
} else {
restaurant.tags = [];
}
});
data.value = restaurants;
}
onMounted(async () => {
await load();
pb.collection("restaurants").subscribe("*", (e) => {
load();
});
pb.collection("reviews").subscribe("*", (e) => {
load();
});
});
const { restaurants } = storeToRefs(useRestaurantsStore());
function onRestaurantSelected(restaurant: Object) {
selectedRestaurant.value = restaurant;
@ -64,7 +31,7 @@ function onDrawerClosed() {
<template>
<div class="flex flex-col-reverse sm:flex-row">
<div class="w-full flex-1">
<RestaurantsTable ref="table" @restaurant-selected="onRestaurantSelected" :data="data" />
<RestaurantsTable ref="table" @restaurant-selected="onRestaurantSelected" :data="restaurants" />
</div>
<div class="flex-1 h-3/6 lg:h-full">
<RestaurantsMap />

View File

@ -1,27 +1,21 @@
<script setup lang="ts">
import { ref } from "vue";
import { pb } from "../pocketbase.ts";
import { useRestaurantsStore } from "../stores/restaurants.ts";
const restaurant = ref<string>();
async function getRandomRestaurant() {
const result = await pb
.collection("restaurants")
.getFirstListItem("", { sort: "@random" });
restaurant.value = result.name;
const store = useRestaurantsStore();
function getRandomRestaurant() {
restaurant.value = store.getRandomRestaurant();
}
</script>
<template>
<div class="flex flex-col place-content-center items-center h-full">
<h2 class="mb-10" v-if="restaurant !== undefined">How about going to...</h2>
<p class="">{{ restaurant }}</p>
<button
class="btn btn-outline btn-primary mt-10"
@click="getRandomRestaurant"
>
Get random restaurant
</button>
<p class="">{{ restaurant?.name }}</p>
<button class="btn btn-outline btn-primary mt-10" @click="getRandomRestaurant">Get random restaurant</button>
</div>
</template>