ux: unify UI based on user-feedback (#1216)

* unify UI based on user-feedback

* fix layout shify error

* implement drag and drop animation
This commit is contained in:
Hayden 2022-05-11 17:14:03 -08:00 committed by GitHub
parent 8f1c082d79
commit 4fe19b88ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 282 additions and 194 deletions

View File

@ -24,19 +24,21 @@
</div> </div>
</v-expand-transition> </v-expand-transition>
</RecipeCardImage> </RecipeCardImage>
<v-card-title class="my-n3 mb-n6"> <v-card-title class="my-n3 px-2 mb-n6">
<div class="headerClass"> <div class="headerClass">
{{ name }} {{ name }}
</div> </div>
</v-card-title> </v-card-title>
<slot name="actions"> <slot name="actions">
<v-card-actions> <v-card-actions class="px-1">
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always /> <RecipeFavoriteBadge v-if="loggedIn" class="absolute" :slug="slug" show-always />
<RecipeRating :value="rating" :name="name" :slug="slug" :small="true" />
<RecipeRating class="pb-1" :value="rating" :name="name" :slug="slug" :small="true" />
<v-spacer></v-spacer> <v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" /> <RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
<RecipeContextMenu <RecipeContextMenu
color="grey darken-2"
:slug="slug" :slug="slug"
:name="name" :name="name"
:recipe-id="recipeId" :recipe-id="recipeId"

View File

@ -40,6 +40,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api"; import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import colors from "vuetify/lib/util/colors";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
export interface ContextMenuIncludes { export interface ContextMenuIncludes {
@ -91,7 +92,7 @@ export default defineComponent({
}, },
color: { color: {
type: String, type: String,
default: "primary", default: colors.grey.darken2,
}, },
slug: { slug: {
type: String, type: String,

View File

@ -8,7 +8,7 @@
</template> </template>
<v-card> <v-card>
<v-app-bar dark color="primary" class="mt-n1 mb-3"> <v-app-bar dense dark color="primary" class="mb-2">
<v-icon large left> <v-icon large left>
{{ $globals.icons.createAlt }} {{ $globals.icons.createAlt }}
</v-icon> </v-icon>
@ -21,34 +21,26 @@
v-model="inputText" v-model="inputText"
outlined outlined
rows="12" rows="12"
hide-details
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')" :placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
> >
</v-textarea> </v-textarea>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn outlined color="info" small v-bind="attrs" @click="trimAllLines" v-on="on">
Trim Whitespace
</v-btn>
</template>
<span> Trim leading and trailing whitespace as well as blank lines </span>
</v-tooltip>
<v-tooltip top> <v-divider></v-divider>
<template #activator="{ on, attrs }"> <template v-for="(util, idx) in utilities">
<v-btn class="ml-1" outlined color="info" small v-bind="attrs" @click="removeFirstCharacter" v-on="on"> <v-list-item :key="util.id" dense class="py-1">
Trim Prefix <v-list-item-title>
</v-btn> <v-list-item-subtitle class="wrap-word">
</template> {{ util.description }}
<span> Trim first character from each line </span> </v-list-item-subtitle>
</v-tooltip> </v-list-item-title>
<v-tooltip top> <BaseButton small color="info" @click="util.action">
<template #activator="{ on, attrs }"> <template #icon> {{ $globals.icons.robot }}</template>
<v-btn class="ml-1" outlined color="info" small v-bind="attrs" @click="splitByNumberedLine" v-on="on"> Run
Split By Numbered Line </BaseButton>
</v-btn> </v-list-item>
</template> <v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
<span> Attempts to split a paragraph by matching 1) or 1. patterns </span> </template>
</v-tooltip>
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
@ -64,7 +56,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { reactive, toRefs, defineComponent } from "@nuxtjs/composition-api"; import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api";
export default defineComponent({ export default defineComponent({
setup(_, context) { setup(_, context) {
const state = reactive({ const state = reactive({
@ -78,7 +70,7 @@ export default defineComponent({
function removeFirstCharacter() { function removeFirstCharacter() {
state.inputText = splitText() state.inputText = splitText()
.map((line) => line.substr(1)) .map((line) => line.substring(1))
.join("\n"); .join("\n");
} }
@ -109,7 +101,28 @@ export default defineComponent({
state.dialog = false; state.dialog = false;
} }
const { i18n } = useContext();
const utilities = [
{
id: "trim-whitespace",
description: i18n.tc("new-recipe.trim-whitespace-description"),
action: trimAllLines,
},
{
id: "trim-prefix",
description: i18n.tc("new-recipe.trim-prefix-description"),
action: removeFirstCharacter,
},
{
id: "split-by-numbered-line",
description: i18n.tc("new-recipe.split-by-numbered-line-description"),
action: splitByNumberedLine,
},
];
return { return {
utilities,
splitText, splitText,
trimAllLines, trimAllLines,
removeFirstCharacter, removeFirstCharacter,

View File

@ -21,18 +21,12 @@
type="number" type="number"
placeholder="Quantity" placeholder="Quantity"
> >
<v-icon <v-icon v-if="$listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
v-if="$listeners && $listeners.delete" {{ $globals.icons.arrowUpDown }}
slot="prepend"
class="mr-n1"
color="error"
@click="$emit('delete')"
>
{{ $globals.icons.delete }}
</v-icon> </v-icon>
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col v-if="!disableAmount && units" sm="12" md="3" cols="12"> <v-col v-if="!disableAmount" sm="12" md="3" cols="12">
<v-autocomplete <v-autocomplete
v-model="value.unit" v-model="value.unit"
:search-input.sync="unitSearch" :search-input.sync="unitSearch"
@ -40,7 +34,7 @@
dense dense
solo solo
return-object return-object
:items="units" :items="units || []"
item-text="name" item-text="name"
class="mx-1" class="mx-1"
placeholder="Choose Unit" placeholder="Choose Unit"
@ -59,7 +53,7 @@
</v-col> </v-col>
<!-- Foods Input --> <!-- Foods Input -->
<v-col v-if="!disableAmount && foods" m="12" md="3" cols="12" class=""> <v-col v-if="!disableAmount" m="12" md="3" cols="12" class="">
<v-autocomplete <v-autocomplete
v-model="value.food" v-model="value.food"
:search-input.sync="foodSearch" :search-input.sync="foodSearch"
@ -67,7 +61,7 @@
dense dense
solo solo
return-object return-object
:items="foods" :items="foods || []"
item-text="name" item-text="name"
class="mx-1 py-0" class="mx-1 py-0"
placeholder="Choose Food" placeholder="Choose Food"
@ -85,28 +79,34 @@
</v-autocomplete> </v-autocomplete>
</v-col> </v-col>
<v-col sm="12" md="" cols="12"> <v-col sm="12" md="" cols="12">
<v-text-field v-model="value.note" hide-details dense solo class="mx-1" placeholder="Notes"> <div class="d-flex">
<v-icon v-if="disableAmount" slot="prepend" class="mr-n1" color="error" @click="$emit('delete')"> <v-text-field v-model="value.note" hide-details dense solo class="mx-1" placeholder="Notes">
{{ $globals.icons.delete }} <v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
</v-icon> {{ $globals.icons.arrowUpDown }}
</v-icon>
<template slot="append-outer"> </v-text-field>
<BaseButtonGroup <BaseButtonGroup
:large="false" hover
class="handle my-auto" :large="false"
:buttons="[ class="my-auto"
{ :buttons="[
icon: $globals.icons.arrowUpDown, {
text: '', icon: $globals.icons.delete,
event: 'open', text: 'Delete',
children: contextMenuOptions, event: 'delete',
}, },
]" {
@toggle-section="toggleTitle" icon: $globals.icons.dotsVertical,
@toggle-original="toggleOriginalText" text: 'Menu',
/> event: 'open',
</template> children: contextMenuOptions,
</v-text-field> },
]"
@toggle-section="toggleTitle"
@toggle-original="toggleOriginalText"
@delete="$emit('delete')"
/>
</div>
</v-col> </v-col>
</v-row> </v-row>
<p v-if="showOriginalText" class="text-caption">Original Text: {{ value.originalText }}</p> <p v-if="showOriginalText" class="text-caption">Original Text: {{ value.originalText }}</p>

View File

@ -71,116 +71,126 @@
:disabled="!edit" :disabled="!edit"
:value="value" :value="value"
handle=".handle" handle=".handle"
v-bind="{
animation: 200,
group: 'description',
ghostClass: 'ghost',
}"
@input="updateIndex" @input="updateIndex"
@start="drag = true" @start="drag = true"
@end="drag = false" @end="drag = false"
> >
<div v-for="(step, index) in value" :key="step.id"> <TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<v-app-bar <div v-for="(step, index) in value" :key="step.id" class="list-group-item">
v-if="showTitleEditor[step.id]" <v-app-bar
class="primary mx-1 mt-6" v-if="showTitleEditor[step.id]"
style="cursor: pointer" class="primary mx-1 mt-6"
dark style="cursor: pointer"
dense dark
rounded
@click="toggleCollapseSection(index)"
>
<v-toolbar-title v-if="!edit" class="headline">
<v-app-bar-title v-text="step.title"> </v-app-bar-title>
</v-toolbar-title>
<v-text-field
v-if="edit"
v-model="step.title"
class="headline pa-0 mt-5"
dense dense
solo rounded
flat @click="toggleCollapseSection(index)"
:placeholder="$t('recipe.section-title')"
background-color="primary"
> >
</v-text-field> <v-toolbar-title v-if="!edit" class="headline">
</v-app-bar> <v-app-bar-title v-text="step.title"> </v-app-bar-title>
<v-hover v-slot="{ hover }"> </v-toolbar-title>
<v-card <v-text-field
class="ma-1" v-if="edit"
:class="[{ 'on-hover': hover }, isChecked(index)]" v-model="step.title"
:elevation="hover ? 12 : 2" class="headline pa-0 mt-5"
:ripple="false" dense
@click="toggleDisabled(index)" solo
> flat
<v-card-title :class="{ 'pb-0': !isChecked(index) }"> :placeholder="$t('recipe.section-title')"
<v-btn v-if="edit" fab x-small color="white" class="mr-2" elevation="0" @click="value.splice(index, 1)"> background-color="primary"
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon> >
</v-btn> </v-text-field>
</v-app-bar>
{{ $t("recipe.step-index", { step: index + 1 }) }} <v-hover v-slot="{ hover }">
<v-card
<template v-if="edit"> class="ma-1"
<v-icon class="handle ml-auto mr-2">{{ $globals.icons.arrowUpDown }}</v-icon> :class="[{ 'on-hover': hover }, isChecked(index)]"
<div> :elevation="hover ? 12 : 2"
<BaseButtonGroup :ripple="false"
:buttons="[ @click="toggleDisabled(index)"
{ >
icon: $globals.icons.dotsVertical, <v-card-title :class="{ 'pb-0': !isChecked(index) }">
text: '', <span class="handle">
event: 'open', <v-icon v-if="edit" size="26" class="pb-1">{{ $globals.icons.arrowUpDown }}</v-icon>
children: [ {{ $t("recipe.step-index", { step: index + 1 }) }}
{ </span>
text: 'Toggle Section', <template v-if="edit">
event: 'toggle-section', <div class="ml-auto">
}, <BaseButtonGroup
{ :large="false"
text: 'Link Ingredients', :buttons="[
event: 'link-ingredients', {
}, icon: $globals.icons.delete,
{ text: $tc('general.delete'),
text: 'Merge Above', event: 'delete',
event: 'merge-above', },
}, {
], icon: $globals.icons.dotsVertical,
}, text: '',
{ event: 'open',
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye, children: [
text: previewStates[index] ? $tc('general.edit') : 'Preview Markdown', {
event: 'preview-step', text: 'Toggle Section',
}, event: 'toggle-section',
]" },
@merge-above="mergeAbove(index - 1, index)" {
@toggle-section="toggleShowTitle(step.id)" text: 'Link Ingredients',
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)" event: 'link-ingredients',
@preview-step="togglePreviewState(index)" },
/> {
text: 'Merge Above',
event: 'merge-above',
},
{
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
text: previewStates[index] ? 'Edit Markdown' : 'Preview Markdown',
event: 'preview-step',
},
],
},
]"
@merge-above="mergeAbove(index - 1, index)"
@toggle-section="toggleShowTitle(step.id)"
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
@preview-step="togglePreviewState(index)"
@delete="value.splice(index, 1)"
/>
</div>
</template>
<v-fade-transition>
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
{{ $globals.icons.checkboxMarkedCircle }}
</v-icon>
</v-fade-transition>
</v-card-title>
<v-card-text v-if="edit">
<MarkdownEditor
v-model="value[index]['text']"
:preview.sync="previewStates[index]"
:display-preview="false"
/>
<div
v-for="ing in step.ingredientReferences"
:key="ing.referenceId"
v-html="getIngredientByRefId(ing.referenceId)"
/>
</v-card-text>
<v-expand-transition>
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
<v-card-text class="markdown">
<VueMarkdown class="markdown" :source="step.text"> </VueMarkdown>
</v-card-text>
</div> </div>
</template> </v-expand-transition>
</v-card>
<v-fade-transition> </v-hover>
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success"> </div>
{{ $globals.icons.checkboxMarkedCircle }} </TransitionGroup>
</v-icon>
</v-fade-transition>
</v-card-title>
<v-card-text v-if="edit">
<MarkdownEditor
v-model="value[index]['text']"
:preview.sync="previewStates[index]"
:display-preview="false"
/>
<div
v-for="ing in step.ingredientReferences"
:key="ing.referenceId"
v-html="getIngredientByRefId(ing.referenceId)"
/>
</v-card-text>
<v-expand-transition>
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
<v-card-text class="markdown">
<VueMarkdown class="markdown" :source="step.text"> </VueMarkdown>
</v-card-text>
</div>
</v-expand-transition>
</v-card>
</v-hover>
</div>
</draggable> </draggable>
</section> </section>
</template> </template>
@ -481,7 +491,10 @@ export default defineComponent({
} }
} }
const drag = ref(false);
return { return {
drag,
togglePreviewState, togglePreviewState,
toggleCollapseSection, toggleCollapseSection,
previewStates, previewStates,
@ -521,4 +534,23 @@ export default defineComponent({
.markdown >>> ol > li { .markdown >>> ol > li {
display: list-item; display: list-item;
} }
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
}
.list-group {
min-height: 38px;
}
.list-group-item {
cursor: move;
}
.list-group-item i {
cursor: pointer;
}
</style> </style>

View File

@ -1,29 +1,27 @@
<template> <template>
<div v-if="value.length > 0 || edit"> <div v-if="value.length > 0 || edit" class="mt-8">
<h2 class="my-4">{{ $t("recipe.note") }}</h2> <h2 class="my-4">{{ $t("recipe.note") }}</h2>
<v-card v-for="(note, index) in value" :key="'note' + index" class="mt-1"> <div v-for="(note, index) in value" :key="'note' + index" class="mt-1">
<div v-if="edit"> <v-card v-if="edit">
<v-card-text> <v-card-text>
<v-row align="center"> <div class="d-flex align-center">
<v-btn fab x-small color="white" class="mr-2" elevation="0" @click="removeByIndex(value, index)"> <v-text-field v-model="value[index]['title']" :label="$t('recipe.title')" />
<v-icon color="error">{{ $globals.icons.delete }}</v-icon> <v-btn icon class="mr-2" elevation="0" @click="removeByIndex(value, index)">
<v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn> </v-btn>
<v-text-field v-model="value[index]['title']" :label="$t('recipe.title')"></v-text-field> </div>
</v-row> <v-textarea v-model="value[index]['text']" auto-grow :placeholder="$t('recipe.note')" />
<v-textarea v-model="value[index]['text']" auto-grow :placeholder="$t('recipe.note')"> </v-textarea>
</v-card-text> </v-card-text>
</div> </v-card>
<div v-else> <div v-else>
<v-card-title class="py-2"> <v-card-title class="text-subtitle-1 font-weight-medium py-1">
{{ note.title }} {{ note.title }}
</v-card-title> </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text> <v-card-text>
<VueMarkdown :source="note.text"> </VueMarkdown> <VueMarkdown :source="note.text"> </VueMarkdown>
</v-card-text> </v-card-text>
</div> </div>
</v-card> </div>
<div v-if="edit" class="d-flex justify-end"> <div v-if="edit" class="d-flex justify-end">
<BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.new") }}</BaseButton> <BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.new") }}</BaseButton>

View File

@ -1,7 +1,7 @@
<template> <template>
<v-item-group> <v-item-group>
<template v-for="btn in buttons"> <template v-for="btn in buttons">
<v-menu v-if="btn.children" :key="'menu-' + btn.event" active-class="pa-0" offset-x left> <v-menu v-if="btn.children" :key="'menu-' + btn.event" active-class="pa-0" offset-y top left>
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
<v-btn tile :large="large" icon v-bind="attrs" v-on="on"> <v-btn tile :large="large" icon v-bind="attrs" v-on="on">
<v-icon> <v-icon>

View File

@ -215,7 +215,10 @@
"upload-a-recipe": "Upload a Recipe", "upload-a-recipe": "Upload a Recipe",
"upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.", "upload-individual-zip-file": "Upload an individual .zip file exported from another Mealie instance.",
"url-form-hint": "Copy and paste a link from your favorite recipe website", "url-form-hint": "Copy and paste a link from your favorite recipe website",
"view-scraped-data": "View Scraped Data" "view-scraped-data": "View Scraped Data",
"trim-whitespace-description": "Trim leading and trailing whitespace as well as blank lines",
"trim-prefix-description": "Trim first character from each line",
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns"
}, },
"page": { "page": {
"404-page-not-found": "404 Page not found", "404-page-not-found": "404 Page not found",

View File

@ -127,14 +127,29 @@
<!-- Advanced Editor --> <!-- Advanced Editor -->
<div v-if="form"> <div v-if="form">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2> <h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<draggable v-if="recipe.recipeIngredient.length > 0" v-model="recipe.recipeIngredient" handle=".handle"> <draggable
<RecipeIngredientEditor v-if="recipe.recipeIngredient.length > 0"
v-for="(ingredient, index) in recipe.recipeIngredient" v-model="recipe.recipeIngredient"
:key="ingredient.referenceId" handle=".handle"
v-model="recipe.recipeIngredient[index]" v-bind="{
:disable-amount="recipe.settings.disableAmount" animation: 200,
@delete="recipe.recipeIngredient.splice(index, 1)" group: 'description',
/> disabled: false,
ghostClass: 'ghost',
}"
@start="drag = true"
@end="drag = false"
>
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
<RecipeIngredientEditor
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
class="list-group-item"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
/>
</TransitionGroup>
</draggable> </draggable>
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader> <v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
<div class="d-flex justify-end mt-2"> <div class="d-flex justify-end mt-2">
@ -355,7 +370,7 @@
:tag-selector="true" :tag-selector="true"
:show-label="false" :show-label="false"
/> />
<RecipeChips v-else :items="recipe.tags" url-prefix="tags"/> <RecipeChips v-else :items="recipe.tags" url-prefix="tags" />
</v-card-text> </v-card-text>
</v-card> </v-card>
@ -820,8 +835,11 @@ export default defineComponent({
return "Parse ingredients"; return "Parse ingredients";
}); });
const drag = ref(false);
return { return {
// Wake Lock // Wake Lock
drag,
wakeIsSupported, wakeIsSupported,
isActive, isActive,
lockScreen, lockScreen,
@ -857,3 +875,24 @@ export default defineComponent({
head: {}, head: {},
}); });
</script> </script>
<style lang="css">
.flip-list-move {
transition: transform 0.5s;
}
.no-move {
transition: transform 0s;
}
.ghost {
opacity: 0.5;
}
.list-group {
min-height: 38px;
}
.list-group-item {
cursor: move;
}
.list-group-item i {
cursor: pointer;
}
</style>