Michael Genson 80968b02bb
feat: Remove Explore URLs and make the normal URLs public (#2632)
* add groupSlug to most routes

* fixed more routing issues

* fixed jank and incorrect routes

* remove public explore links

* remove unused groupSlug and explore routes

* nuked explore pages

* fixed public toolstore bug

* fixed various routes missing group slug

* restored public app header menu

* fix janky login redirect

* 404 recipe API call returns to login

* removed unused explore layout

* force redirect when using the wrong group slug

* fixed dead admin links

* removed unused middleware from earlier attempt

* 🧹

* improve cookbooks sidebar
fixed sidebar link not working
fixed sidebar link target
hide cookbooks header when there are none

* added group slug to user

* fix $auth typehints

* vastly simplified groupSlug logic

* allow logged-in users to view other groups

* fixed some edgecases that bypassed isOwnGroup

* fixed static home ref

* 🧹

* fixed redirect logic

* lint warning

* removed group slug from group and user pages
refactored all components to use route groupSlug or user group slug
moved some group pages to recipe pages

* fixed some bad types

* 🧹

* moved groupSlug routes under /g/groupSlug

* move /recipe/ to /r/

* fix backend url generation and metadata injection

* moved shopping lists to root/other route fixes

* changed shared from /recipes/ to /r/

* fixed 404 redirect not awaiting

* removed unused import

* fix doc links

* fix public recipe setting not affecting public API

* fixed backend tests

* fix nuxt-generate command

---------

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
2023-11-05 16:07:02 -09:00

339 lines
11 KiB
Vue

<template>
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
<RecipePageHeader
:recipe="recipe"
:recipe-scale="scale"
:landscape="landscape"
@save="saveRecipe"
@delete="deleteRecipe"
/>
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
<v-card-text v-else>
<!--
This is where most of the main content is rendered. Some components include state for both Edit and View modes
which is why some have explicit v-if statements and others use the composition API to determine and manage
the shared state internally.
The global recipe object is shared down the tree of components and _is_ mutated by child components. This is
some-what of a hack of the system and goes against the principles of Vue, but it _does_ seem to work and streamline
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
data management and mutation system we're using.
-->
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
<RecipePageTitleContent :recipe="recipe" :landscape="landscape" />
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
<RecipePageScale :recipe="recipe" :scale.sync="scale" :landscape="landscape" />
<!--
This section contains the 2 column layout for the recipe steps and other content.
-->
<v-row>
<!--
The left column is conditionally rendered based on cook mode.
-->
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" />
</v-col>
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
<!--
the right column is always rendered, but it's layout width is determined by where the left column is
rendered.
-->
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
<RecipePageInstructions
v-model="recipe.recipeInstructions"
:assets.sync="recipe.assets"
:recipe="recipe"
:scale="scale"
/>
<div v-if="isEditForm" class="d-flex">
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
</div>
<div v-if="!$vuetify.breakpoint.mdAndUp">
<RecipePageOrganizers :recipe="recipe" />
</div>
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
</v-col>
</v-row>
<RecipePageFooter :recipe="recipe" />
</v-card-text>
</v-card>
<div
v-if="recipe && wakeIsSupported"
class="d-print-none d-flex px-2"
:class="$vuetify.breakpoint.smAndDown ? 'justify-center' : 'justify-end'"
>
<v-switch v-model="wakeLock" small :label="$t('recipe.screen-awake')" />
</div>
<RecipePageComments
v-if="isOwnGroup && !recipe.settings.disableComments && !isEditForm && !isCookMode"
:recipe="recipe"
class="px-1 my-4 d-print-none"
/>
<RecipePrintContainer :recipe="recipe" :scale="scale" />
</v-container>
</template>
<script lang="ts">
import {
defineComponent,
useContext,
useRouter,
computed,
ref,
onMounted,
onUnmounted,
useRoute,
} from "@nuxtjs/composition-api";
import { invoke, until, useWakeLock } from "@vueuse/core";
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
import RecipePageFooter from "./RecipePageParts/RecipePageFooter.vue";
import RecipePageHeader from "./RecipePageParts/RecipePageHeader.vue";
import RecipePageIngredientEditor from "./RecipePageParts/RecipePageIngredientEditor.vue";
import RecipePageIngredientToolsView from "./RecipePageParts/RecipePageIngredientToolsView.vue";
import RecipePageInstructions from "./RecipePageParts/RecipePageInstructions.vue";
import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue";
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
import { EditorMode, PageMode, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { Recipe } from "~/lib/api/types/recipe";
import { useRouteQuery } from "~/composables/use-router";
import { useUserApi } from "~/composables/api";
import { uuid4, deepCopy } from "~/composables/use-utils";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
const EDITOR_OPTIONS = {
mode: "code",
search: false,
mainMenuBar: false,
};
export default defineComponent({
components: {
RecipePageHeader,
RecipePrintContainer,
RecipePageComments,
RecipePageTitleContent,
RecipePageEditorToolbar,
RecipePageIngredientEditor,
RecipePageOrganizers,
RecipePageScale,
RecipePageIngredientToolsView,
RecipeDialogBulkAdd,
RecipeNotes,
RecipePageInstructions,
RecipePageFooter,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
},
setup(props) {
const { $auth, $vuetify } = useContext();
const route = useRoute();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const { isOwnGroup } = useLoggedInState();
const router = useRouter();
const api = useUserApi();
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
usePageState(props.recipe.slug);
/** =============================================================
* Recipe Snapshot on Mount
* this is used to determine if the recipe has been changed since the last save
* and prompts the user to save if they have unsaved changes.
*/
const originalRecipe = ref<Recipe | null>(null);
invoke(async () => {
await until(props.recipe).not.toBeNull();
originalRecipe.value = deepCopy(props.recipe);
});
onUnmounted(async () => {
const isSame = JSON.stringify(props.recipe) === JSON.stringify(originalRecipe.value);
if (isEditMode.value && !isSame && props.recipe?.slug !== undefined) {
const save = window.confirm(
"You have unsaved changes. Do you want to save before leaving?\n\nOkay to save, Cancel to discard changes."
);
if (save) {
await api.recipes.updateOne(props.recipe.slug, props.recipe);
}
}
});
/** =============================================================
* Set State onMounted
*/
type BooleanString = "true" | "false" | "";
const edit = useRouteQuery<BooleanString>("edit", "");
onMounted(() => {
if (edit.value === "true") {
setMode(PageMode.EDIT);
}
});
/** =============================================================
* Wake Lock
*/
const { isSupported: wakeIsSupported, isActive, request, release } = useWakeLock();
const wakeLock = computed({
get: () => isActive,
set: () => {
if (isActive.value) {
unlockScreen();
} else {
lockScreen();
}
},
});
async function lockScreen() {
if (wakeIsSupported) {
console.log("Wake Lock Requested");
await request("screen");
}
}
async function unlockScreen() {
if (wakeIsSupported || isActive) {
console.log("Wake Lock Released");
await release();
}
}
onMounted(() => lockScreen());
onUnmounted(() => unlockScreen());
/** =============================================================
* Recipe Save Delete
*/
async function saveRecipe() {
const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe);
setMode(PageMode.VIEW);
if (data?.slug) {
router.push(`/g/${groupSlug.value}/r/` + data.slug);
}
}
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.recipe.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
}
/** =============================================================
* View Preferences
*/
const landscape = computed(() => {
const preferLandscape = props.recipe.settings.landscapeView;
const smallScreen = !$vuetify.breakpoint.smAndUp;
if (preferLandscape) {
return true;
} else if (smallScreen) {
return true;
}
return false;
});
/** =============================================================
* Bulk Step Editor
* TODO: Move to RecipePageInstructions component
*/
function addStep(steps: Array<string> | null = null) {
if (!props.recipe.recipeInstructions) {
return;
}
if (steps) {
const cleanedSteps = steps.map((step) => {
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
});
props.recipe.recipeInstructions.push(...cleanedSteps);
} else {
props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
}
}
/** =============================================================
* Meta Tags
*/
const { user } = usePageUser();
return {
user,
isOwnGroup,
api,
scale: ref(1),
EDITOR_OPTIONS,
landscape,
pageMode,
editMode,
PageMode,
EditorMode,
isEditMode,
isEditForm,
isEditJSON,
isCookMode,
toggleCookMode,
wakeLock,
wakeIsSupported,
saveRecipe,
deleteRecipe,
addStep,
};
},
head: {},
});
</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>