feat(frontend): Fractional Scaling

This commit is contained in:
hay-kot 2021-08-27 20:05:02 -08:00
parent d1a7ec3b95
commit 5ba337ab11
11 changed files with 306 additions and 126 deletions

View File

@ -5,6 +5,7 @@ const prefix = "/api";
export interface CreateUnit {
name: string;
abbreviation: string;
fraction: boolean;
description: string;
}

View File

@ -1,6 +1,5 @@
import { BaseCRUDAPI } from "./_base";
import { Recipe , CreateRecipe } from "~/types/api-types/recipe";
import { Recipe, CreateRecipe } from "~/types/api-types/recipe";
const prefix = "/api";

View File

@ -27,7 +27,7 @@
</v-text-field>
</v-col>
<v-col v-if="!disableAmount && units" sm="12" md="3" cols="12">
<v-select
<v-autocomplete
v-model="value.unit"
hide-details
dense
@ -38,10 +38,13 @@
class="mx-1"
placeholder="Choose Unit"
>
</v-select>
<template #no-data>
<RecipeIngredientUnitDialog class="mx-2" block small />
</template>
</v-autocomplete>
</v-col>
<v-col v-if="!disableAmount && foods" m="12" md="3" cols="12" class="">
<v-select
<v-autocomplete
v-model="value.food"
hide-details
dense
@ -52,7 +55,10 @@
class="mx-1 py-0"
placeholder="Choose Food"
>
</v-select>
<template #no-data>
<RecipeIngredientFoodDialog class="mx-2" block small />
</template>
</v-autocomplete>
</v-col>
<v-col sm="12" md="" cols="12">
<v-text-field v-model="value.note" hide-details dense solo class="mx-1" placeholder="Notes">
@ -81,10 +87,14 @@
<script lang="ts">
import { defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
import RecipeIngredientUnitDialog from "./RecipeIngredientUnitDialog.vue";
import RecipeIngredientFoodDialog from "./RecipeIngredientFoodDialog.vue";
import { useFoods } from "~/composables/use-recipe-foods";
import { useUnits } from "~/composables/use-recipe-units";
import { validators } from "~/composables/use-validators";
export default defineComponent({
components: { RecipeIngredientUnitDialog, RecipeIngredientFoodDialog },
props: {
value: {
type: Object,
@ -99,7 +109,7 @@ export default defineComponent({
const { value } = props;
const { foods } = useFoods();
const { units } = useUnits();
const { units, workingUnitData, actions: unitActions } = useUnits();
const state = reactive({
showTitle: false,
@ -115,8 +125,14 @@ export default defineComponent({
}
}
return { foods, units, ...toRefs(state), toggleTitle };
return { workingUnitData, unitActions, validators, foods, units, ...toRefs(state), toggleTitle };
},
});
</script>
<style >
.v-input__append-outer {
margin: 0 !important;
padding: 0 !important;
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<BaseDialog
title="Create Food"
:icon="$globals.icons.foods"
:keep-open="!validForm"
@submit="actions.createOne(domCreateFoodForm)"
>
<v-card-text>
<v-form ref="domCreateFoodForm">
<v-text-field v-model="workingFoodData.name" label="Name" :rules="[validators.required]"></v-text-field>
<v-text-field v-model="workingFoodData.description" label="Description"></v-text-field>
</v-form>
</v-card-text>
<template #activator="{ open }">
<BaseButton
v-bind="$attrs"
@click="
actions.resetWorking();
open();
"
></BaseButton>
</template>
</BaseDialog>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useFoods } from "~/composables/use-recipe-foods";
import { validators } from "~/composables/use-validators";
export default defineComponent({
setup() {
const domCreateFoodForm = ref(null);
const { workingFoodData, actions, validForm } = useFoods();
return { validators, workingFoodData, actions, domCreateFoodForm, validForm };
},
});
</script>

View File

@ -0,0 +1,40 @@
<template>
<BaseDialog
title="Create Unit"
:icon="$globals.icons.units"
:keep-open="!validForm"
@submit="actions.createOne(domCreateUnitForm)"
>
<v-card-text>
<v-form ref="domCreateUnitForm">
<v-text-field v-model="workingUnitData.name" label="Name" :rules="[validators.required]"></v-text-field>
<v-text-field v-model="workingUnitData.abbreviation" label="Abbreviation"></v-text-field>
<v-text-field v-model="workingUnitData.description" label="Description"></v-text-field>
<v-switch v-model="workingUnitData.fraction" label="Display as Fraction"></v-switch>
</v-form>
</v-card-text>
<template #activator="{ open }">
<BaseButton
v-bind="$attrs"
@click="
actions.resetWorking();
open();
"
></BaseButton>
</template>
</BaseDialog>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useUnits } from "~/composables/use-recipe-units";
import { validators } from "~/composables/use-validators";
export default defineComponent({
setup() {
const domCreateUnitForm = ref(null);
const { workingUnitData, actions, validForm } = useUnits();
return { validators, workingUnitData, actions, validForm, domCreateUnitForm };
},
});
</script>

View File

@ -1,68 +1,15 @@
<template>
<div v-if="edit || (value && value.length > 0)">
<div v-if="value && value.length > 0">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<div v-if="edit">
<draggable :value="value" handle=".handle" @input="updateIndex" @start="drag = true" @end="drag = false">
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
<div v-for="(ingredient, index) in value" :key="generateKey('ingredient', index)">
<v-row align="center">
<v-text-field
v-if="edit && showTitleEditor[index]"
v-model="value[index].title"
class="mx-3 mt-3"
dense
:label="$t('recipe.section-title')"
>
</v-text-field>
<v-textarea
v-model="value[index].note"
class="mr-2"
:label="$t('recipe.ingredient')"
auto-grow
solo
dense
rows="1"
>
<template slot="append">
<v-tooltip right nudge-right="10">
<template #activator="{ on, attrs }">
<v-btn icon small class="mt-n1" v-bind="attrs" v-on="on" @click="toggleShowTitle(index)">
<v-icon>{{ showTitleEditor[index] ? $globals.icons.minus : $globals.icons.createAlt }}</v-icon>
</v-btn>
</template>
<span>{{
showTitleEditor[index] ? $t("recipe.remove-section") : $t("recipe.insert-section")
}}</span>
</v-tooltip>
</template>
<template slot="append-outer">
<v-icon class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
</template>
<v-icon slot="prepend" class="mr-n1" color="error" @click="removeByIndex(value, index)">
{{ $globals.icons.delete }}
</v-icon>
</v-textarea>
</v-row>
</div>
</transition-group>
</draggable>
<div class="d-flex row justify-end">
<RecipeDialogBulkAdd class="mr-2" @bulk-data="addIngredient" />
<v-btn color="secondary" dark class="mr-4" @click="addIngredient">
<v-icon>{{ $globals.icons.create }}</v-icon>
</v-btn>
</div>
</div>
<div v-else>
<div>
<div v-for="(ingredient, index) in value" :key="generateKey('ingredient', index)">
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
<v-divider v-if="showTitleEditor[index]"></v-divider>
<v-list-item dense @click="toggleChecked(index)">
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary"> </v-checkbox>
<v-list-item-content>
<VueMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredient.note"> </VueMarkdown>
<VueMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="parseIngredientText(ingredient)">
</VueMarkdown>
</v-list-item-content>
</v-list-item>
</div>
@ -72,13 +19,10 @@
<script>
import VueMarkdown from "@adapttive/vue-markdown";
import draggable from "vuedraggable";
import { useFraction } from "@/composables/use-fraction";
import { utils } from "@/utils";
import RecipeDialogBulkAdd from "./RecipeDialogBulkAdd";
export default {
components: {
RecipeDialogBulkAdd,
draggable,
VueMarkdown,
},
props: {
@ -86,11 +30,42 @@ export default {
type: Array,
default: () => [],
},
edit: {
disableAmount: {
type: Boolean,
default: false,
},
scale: {
type: Number,
default: 1,
},
},
setup(props) {
const { frac } = useFraction();
function parseIngredientText(ingredient) {
if (props.disableAmount) {
return ingredient.note;
}
const { quantity, food, unit, note } = ingredient;
let return_qty = "";
if (unit?.fraction) {
const fraction = frac(quantity * props.scale, 10, true);
if (fraction[0] !== undefined && fraction[0] > 0) {
return_qty += fraction[0];
}
if (fraction[1] > 0) {
return_qty += ` <sup>${fraction[1]}</sup>&frasl;<sub>${fraction[2]}</sub>`;
}
} else {
return_qty = quantity;
}
return `${return_qty} ${unit?.name || " "} ${food?.name || " "} ${note}`;
}
return { parseIngredientText };
},
data() {
return {
@ -111,42 +86,14 @@ export default {
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
},
methods: {
addIngredient(ingredients = null) {
if (ingredients.length) {
const newIngredients = ingredients.map((x) => {
return {
title: null,
note: x,
unit: null,
food: null,
disableAmount: true,
quantity: 1,
};
});
this.value.push(...newIngredients);
} else {
this.value.push({
title: null,
note: "",
unit: null,
food: null,
disableAmount: true,
quantity: 1,
});
}
},
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
updateIndex(data) {
this.$emit("input", data);
},
toggleChecked(index) {
this.$set(this.checked, index, !this.checked[index]);
},
removeByIndex(list, index) {
list.splice(index, 1);
},
validateTitle(title) {
return !(title === null || title === "");
},

View File

@ -0,0 +1,76 @@
/* frac.js (C) 2012-present SheetJS -- http://sheetjs.com */
/* https://developer.aliyun.com/mirror/npm/package/frac/v/0.3.0 Apache license */
function frac(x: number, D: number, mixed: Boolean) {
let n1 = Math.floor(x);
let d1 = 1;
let n2 = n1 + 1;
let d2 = 1;
if (x !== n1)
while (d1 <= D && d2 <= D) {
const m = (n1 + n2) / (d1 + d2);
if (x === m) {
if (d1 + d2 <= D) {
d1 += d2;
n1 += n2;
d2 = D + 1;
} else if (d1 > d2) d2 = D + 1;
else d1 = D + 1;
break;
} else if (x < m) {
n2 = n1 + n2;
d2 = d1 + d2;
} else {
n1 = n1 + n2;
d1 = d1 + d2;
}
}
if (d1 > D) {
d1 = d2;
n1 = n2;
}
if (!mixed) return [0, n1, d1];
const q = Math.floor(n1 / d1);
return [q, n1 - q * d1, d1];
}
function cont(x: number, D: number, mixed: Boolean) {
const sgn = x < 0 ? -1 : 1;
let B = x * sgn;
let P_2 = 0;
let P_1 = 1;
let P = 0;
let Q_2 = 1;
let Q_1 = 0;
let Q = 0;
let A = Math.floor(B);
while (Q_1 < D) {
A = Math.floor(B);
P = A * P_1 + P_2;
Q = A * Q_1 + Q_2;
if (B - A < 0.00000005) break;
B = 1 / (B - A);
P_2 = P_1;
P_1 = P;
Q_2 = Q_1;
Q_1 = Q;
}
if (Q > D) {
if (Q_1 > D) {
Q = Q_2;
P = P_2;
} else {
Q = Q_1;
P = P_1;
}
}
if (!mixed) return [0, sgn * P, Q];
const q = Math.floor((sgn * P) / Q);
return [q, sgn * P - q * Q, Q];
}
export const useFraction = function () {
return {
frac,
cont,
};
};

View File

@ -1,8 +1,10 @@
import { useAsync, ref, reactive } from "@nuxtjs/composition-api";
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { Food } from "~/api/class-interfaces/recipe-foods";
let foodStore: Ref<Food[] | null> | null = null;
export const useFoods = function () {
const api = useApiSingleton();
const loading = ref(false);
@ -23,15 +25,15 @@ export const useFoods = function () {
return data;
}, useAsyncKey());
loading.value = false
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.foods.getAll();
if (data) {
foods.value = data;
if (data && foodStore) {
foodStore.value = data;
}
loading.value = false;
@ -44,8 +46,8 @@ export const useFoods = function () {
loading.value = true;
const { data } = await api.foods.createOne(workingFoodData);
if (data && foods.value) {
foods.value.push(data);
if (data && foodStore?.value) {
foodStore.value.push(data);
} else {
this.refreshAll();
}
@ -61,7 +63,7 @@ export const useFoods = function () {
loading.value = true;
const { data } = await api.foods.updateOne(workingFoodData.id, workingFoodData);
if (data && foods.value) {
if (data && foodStore?.value) {
this.refreshAll();
}
loading.value = false;
@ -69,7 +71,7 @@ export const useFoods = function () {
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.foods.deleteOne(id);
if (data && foods.value) {
if (data && foodStore?.value) {
this.refreshAll();
}
},
@ -83,9 +85,14 @@ export const useFoods = function () {
workingFoodData.name = item.name;
workingFoodData.description = item.description;
},
flushStore() {
foodStore = null;
},
};
const foods = actions.getAll();
if (!foodStore) {
foodStore = actions.getAll();
}
return { foods, workingFoodData, deleteTargetId, actions, validForm };
return { foods: foodStore, workingFoodData, deleteTargetId, actions, validForm };
};

View File

@ -1,8 +1,10 @@
import { useAsync, ref, reactive } from "@nuxtjs/composition-api";
import { useAsync, ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { Unit } from "~/api/class-interfaces/recipe-units";
let unitStore: Ref<Unit[] | null> | null = null;
export const useUnits = function () {
const api = useApiSingleton();
const loading = ref(false);
@ -12,6 +14,7 @@ export const useUnits = function () {
const workingUnitData = reactive({
id: 0,
name: "",
fraction: true,
abbreviation: "",
description: "",
});
@ -24,15 +27,15 @@ export const useUnits = function () {
return data;
}, useAsyncKey());
loading.value = false
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.units.getAll();
if (data) {
units.value = data;
if (data && unitStore) {
unitStore.value = data;
}
loading.value = false;
@ -45,8 +48,8 @@ export const useUnits = function () {
loading.value = true;
const { data } = await api.units.createOne(workingUnitData);
if (data && units.value) {
units.value.push(data);
if (data && unitStore?.value) {
unitStore.value.push(data);
} else {
this.refreshAll();
}
@ -62,7 +65,7 @@ export const useUnits = function () {
loading.value = true;
const { data } = await api.units.updateOne(workingUnitData.id, workingUnitData);
if (data && units.value) {
if (data && unitStore?.value) {
this.refreshAll();
}
loading.value = false;
@ -70,7 +73,7 @@ export const useUnits = function () {
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.units.deleteOne(id);
if (data && units.value) {
if (data && unitStore?.value) {
this.refreshAll();
}
},
@ -83,12 +86,18 @@ export const useUnits = function () {
setWorking(item: Unit) {
workingUnitData.id = item.id;
workingUnitData.name = item.name;
workingUnitData.fraction = item.fraction;
workingUnitData.abbreviation = item.abbreviation;
workingUnitData.description = item.description;
},
flushStore() {
unitStore = null;
},
};
const units = actions.getAll();
if (!unitStore) {
unitStore = actions.getAll();
}
return { units, workingUnitData, deleteTargetId, actions, validForm };
return { units: unitStore, workingUnitData, deleteTargetId, actions, validForm };
};

View File

@ -1,5 +1,5 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import {set } from "@vueuse/core"
import { set } from "@vueuse/core";
import { useAsyncKey } from "./use-utils";
import { useApiSingleton } from "~/composables/use-api";
import { Recipe } from "~/types/api-types/recipe";
@ -31,7 +31,6 @@ export const useRecipes = (all = false, fetchRecipes = true) => {
const { data } = await api.recipes.getAll(start, end);
if (data) {
set(recipes, data);
}
}

View File

@ -127,8 +127,22 @@
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
{{ scaledYield }}
</v-btn>
<template v-if="!recipe.settings.disableAmount">
<v-btn color="secondary darken-1" class="mx-1" small @click="scale > 1 ? scale-- : null">
<v-icon>
{{ $globals.icons.minus }}
</v-icon>
</v-btn>
<v-btn color="secondary darken-1" small @click="scale++">
<v-icon>
{{ $globals.icons.createAlt }}
</v-icon>
</v-btn>
</template>
<v-spacer></v-spacer>
<RecipeRating
v-if="recipe.settings.landscapeView"
:key="recipe.slug"
@ -137,9 +151,15 @@
:slug="recipe.slug"
/>
</div>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<RecipeIngredients v-if="!form" :value="recipe.recipeIngredient" />
<RecipeIngredients
v-if="!form"
:value="recipe.recipeIngredient"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
/>
<!-- Recipe Categories -->
<div v-if="$vuetify.breakpoint.mdAndUp" class="mt-5">
@ -202,7 +222,16 @@
</template>
<script lang="ts">
import { defineComponent, ref, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
import {
computed,
defineComponent,
reactive,
ref,
toRefs,
useMeta,
useRoute,
useRouter,
} from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown";
import draggable from "vuedraggable";
@ -277,6 +306,19 @@ export default defineComponent({
}
}
const scaledYield = computed(() => {
const regMatchNum = /\d+/;
const yieldString = recipe.value?.recipeYield;
const num = yieldString?.match(regMatchNum);
if (num && num?.length > 0) {
const yieldAsInt = parseInt(num[0]);
return yieldString?.replace(num[0], String(yieldAsInt * state.scale));
}
return recipe.value?.recipeYield;
});
async function uploadImage(fileObject: File) {
if (!recipe.value) {
return;
@ -320,7 +362,13 @@ export default defineComponent({
}
}
const state = reactive({
scale: 1,
});
return {
scaledYield,
...toRefs(state),
imageKey,
recipe,
api,