refactor: use nuxt compatiable 4 folder

This commit is contained in:
YunYouJun
2024-09-15 18:07:50 +08:00
parent 7a52b024dd
commit 41bdc3346f
96 changed files with 2577 additions and 2673 deletions

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import { isClient } from '@vueuse/core'
const displayICP = ref(true)
onBeforeMount(() => {
if (isClient)
displayICP.value = ['cook.yunyoujun.cn', 'localhost', '127.0.0.1'].includes(window.location.hostname)
})
</script>
<template>
<div p="4 t-2" class="flex flex-col items-center justify-center" text="sm">
<CurrentVersion />
<a v-if="displayICP" opacity="80" class="flex" href="https://beian.miit.gov.cn/" target="_blank">
苏ICP备17038157号
</a>
<div m="t-2" class="inline-flex items-center justify-center" text="xs">
<a class="inline-flex items-center justify-center" style="color: #ea7b99" href="https://www.bilibili.com/blackboard/dynamic/306882" target="_blank">
<span inline-flex>菜谱视频来源</span>
<div class="inline-flex" i-ri-bilibili-line />
<span m="l-1" class="inline-flex" style="margin-top: 1px;">B </span>
</a>
</div>
<div mt-2>
本站点由
<a color="#F6821F" href="https://www.cloudflare-cn.com/" target="_blank" title="Cloudflare" border="b-1 dashed">
<span>Cloudflare</span>
</a>
提供 CDN 支持
</div>
<div m="t-2" opacity="80" class="flex items-center justify-center">
©&nbsp;<a href="https://github.com/YunYouJun/cook" target="_blank">Cook</a>
<div text="xs" m="x-1" i-ri-cloud-line />
<a href="https://www.yunyoujun.cn" target="_blank">云游君</a>
</div>
<div m="t-2" opacity="80">
<a href="https://yunle.fun" target="_blank" title="云乐坊">
云乐坊工作室
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
const props = defineProps({
isVisible: Boolean,
})
const rStore = useRecipeStore()
const { displayedRecipe } = storeToRefs(rStore)
/**
* Show basket button if there are recipes in the basket
*/
const showBasketBtn = computed(() => {
return displayedRecipe.value.length !== rStore.recipesLength && props.isVisible
})
</script>
<template>
<button
v-show="showBasketBtn"
class="fixed z-9 inline-flex cursor-pointer items-center justify-center rounded rounded-full shadow hover:shadow-md"
bg="green-50 dark:green-900" w="10" h="10"
bottom="22"
right="4"
text="green-600 dark:green-300"
>
<span v-if="displayedRecipe.length > 0">
<div i-mdi-bowl-mix-outline />
</span>
<span v-else>
<div i-mdi-bowl-outline />
</span>
</button>
</template>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import type { StuffItem } from '~/types'
import { meat, staple, tools, vegetable } from '~/data/food'
import { useEmojiAnimation } from '~/composables/animation'
const rStore = useRecipeStore()
const { curTool } = storeToRefs(rStore)
const curStuff = computed(() => rStore.selectedStuff)
const recipeBtnRef = ref<HTMLButtonElement>()
const { playAnimation } = useEmojiAnimation(recipeBtnRef)
const gtm = useGtm()
const recipePanelRef = ref()
const { isVisible, show } = useInvisibleElement(recipePanelRef)
function toggleStuff(item: StuffItem, category = '', _e?: Event) {
rStore.toggleStuff(item.name)
if (curStuff.value.includes(item.name))
playAnimation(item.emoji)
gtm?.trackEvent({
event: 'click',
category: `${category}_${item.name}`,
action: 'click_stuff',
label: '食材',
})
gtm?.trackEvent({
event: 'click_stuff',
action: item.name,
})
}
</script>
<template>
<div>
<h2 m="t-4" text="xl" font="bold" p="1">
🥘 先选一下食材
</h2>
<div>
<h2 opacity="90" text="base" font="bold" p="1">
🥬 菜菜们
</h2>
<div>
<VegetableTag
v-for="item, i in vegetable" :key="i"
:active="curStuff.includes(item.name)"
@click="toggleStuff(item, 'vegetable')"
>
<span v-if="item.emoji" class="inline-flex">{{ item.emoji }}</span>
<span v-else-if="item.image" class="inline-flex">
<img class="inline-flex" w="2" h="2" width="10" height="10" :src="item.image" :alt="item.name">
</span>
<span class="inline-flex" m="l-1">{{ item.name }}</span>
</VegetableTag>
</div>
</div>
<div m="y-4">
<h2 opacity="90" text="base" font="bold" p="1">
🥩 肉肉们
</h2>
<div>
<MeatTag
v-for="item, i in meat" :key="i"
:active="curStuff.includes(item.name)"
@click="toggleStuff(item, 'meat')"
>
<span>{{ item.emoji }}</span>
<span m="l-1">{{ item.name }}</span>
</MeatTag>
</div>
</div>
<div m="y-4">
<h2 opacity="90" text="base" font="bold" p="1">
🍚 主食也要一起下锅吗不选也行
</h2>
<div>
<StapleTag
v-for="item, i in staple" :key="i"
:active="curStuff.includes(item.name)"
@click="toggleStuff(item, 'staple')"
>
<span>{{ item.emoji }}</span>
<span m="l-1">{{ item.name }}</span>
</StapleTag>
</div>
</div>
<div m="t-4">
<h2 text="xl" font="bold" p="1">
🍳 再选一下厨具
</h2>
<div>
<ToolTag
v-for="item, i in tools" :key="i"
:active="curTool === item.name"
@click="rStore.clickTool(item)"
>
<span v-if="item.emoji" class="inline-flex">
{{ item.emoji }}
</span>
<span v-else-if="item.icon" class="inline-flex">
<div :class="item.icon" />
</span>
<span class="inline-flex" m="l-1">{{ item.label || item.name }}</span>
</ToolTag>
</div>
</div>
<Transition>
<BasketButton ref="recipeBtnRef" :is-visible="isVisible" @click="show" />
</Transition>
<RecipePanel ref="recipePanelRef" />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<h1 text-2xl font="bold" my="4">
<slot />
</h1>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
const props = defineProps<{
initial: number
}>()
const { count, inc, dec } = useCounter(props.initial)
</script>
<template>
<div>
{{ count }}
<button class="inc" @click="inc()">
+
</button>
<button class="dec" @click="dec()">
-
</button>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script lang="ts" setup>
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
defineProps<{
title: string
defaultOpen?: boolean
}>()
</script>
<template>
<Disclosure v-slot="{ open }" :default-open="defaultOpen" as="div" class="mt-2">
<DisclosureButton
class="w-full flex justify-between rounded-lg bg-blue-100 px-4 py-2 text-left text-sm text-blue-900 font-medium hover:bg-blue-200 focus:outline-none focus-visible:ring focus-visible:ring-blue-500 focus-visible:ring-opacity-75"
>
<span>{{ title }}</span>
<div
i-ri-arrow-drop-up-line
:class="open ? 'rotate-180 transform' : ''"
class="h-5 w-5 text-blue-500"
/>
</DisclosureButton>
<DisclosurePanel class="px-2 pb-2 pt-4 text-sm">
<slot />
</DisclosurePanel>
</Disclosure>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { links } from '~/constants'
</script>
<template>
<div>
<a
m="2"
class="feedback-button"
:href="links.contribute" target="_blank"
title="居家菜谱投稿"
>
立即投稿
</a>
<a
m="2"
class="feedback-button"
:href="links.feedback" target="_blank"
alt="通过兔小巢反馈"
>
立即反馈
</a>
</div>
</template>
<style>
.feedback-button {
@apply border-none inline-flex justify-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600;
}
</style>

7
app/components/README.md Normal file
View File

@@ -0,0 +1,7 @@
## Components
Components in this dir will be auto-registered and on-demand, powered by [`unplugin-vue-components`](https://github.com/antfu/unplugin-vue-components).
### Icons
You can use icons from almost any icon sets by the power of [Iconify](https://iconify.design/).

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
const { count, inc, dec } = useCount()
const { random, randomRecipes } = useRandomRecipe(count)
</script>
<template>
<div inline-flex m="y-3">
<button rounded-full p-2 btn @click="dec()">
<div i-carbon-subtract />
</button>
<div font="mono" w="15" m-auto inline-block>
{{ count }}
</div>
<button rounded-full p-2 btn @click="inc()">
<div i-carbon-add />
</button>
</div>
<button cursor-pointer class="inline-flex inline-flex items-center justify-center rounded-md border-none bg-blue-600 px-3 py-1.5 text-sm text-white font-semibold leading-6 shadow-sm hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-blue-600 focus-visible:outline-offset-2 focus-visible:outline" @click="random">
<div class="transition" hover="text-blue-500" i-ri-refresh-line mr-1 inline-flex />
<div>随机一下</div>
</button>
<div v-show="randomRecipes.length > 0">
<div m="t-8" flex="~ col">
<template v-for="recipe, i in randomRecipes" :key="i">
<DishTag v-if="recipe" :dish="recipe" />
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
const rStore = useRecipeStore()
const { selectedStuff, curTool } = storeToRefs(rStore)
const showSearchInput = ref(false)
const showTooltip = computed(() => !selectedStuff.value.length && !curTool.value)
</script>
<template>
<div
class="recipe-panel relative shadow transition hover:shadow-md"
m="x-2 y-4" p="2"
bg="gray-400/8"
>
<RecipePanelTitle />
<ToggleMode />
<button absolute right-4 top-4 @click="showSearchInput = !showSearchInput">
<div v-if="!showSearchInput" i-ri-search-line />
<div v-else i-ri-search-fill />
</button>
<div class="cook-recipes" p="2">
<SearchFoodInput v-if="showSearchInput" />
<Transition mode="out-in">
<span v-if="showTooltip" text="sm" p="2">
你要先选食材或工具哦
</span>
<div
v-else-if="rStore.isSearching"
relative flex items-center justify-center p-6
text-xl
>
<div class="magnifying-glass" i-ri-search-line inline-flex />
</div>
<div v-else-if="rStore.displayedRecipe.length">
<DishTag v-for="item, i in rStore.displayedRecipe" :key="i" :dish="item" />
</div>
<div v-else text="sm">
<span>还没有完美匹配的菜谱呢</span>
<br>
<span>大胆尝试一下或者</span>
<a href="#" @click="rStore.reset()">
<strong>换个组合</strong>
</a>
<span></span>
<br>
<div m="t-1">
<span>欢迎来</span>
<a class="text-blue-600 font-bold dark:text-blue-400" href="https://docs.qq.com/sheet/DQk1vdkhFV0twQVNS?tab=uykkic" target="_blank">这里</a>
<span>反馈新的菜谱</span>
</div>
</div>
</Transition>
</div>
</div>
</template>
<style>
@keyframes circle-rotate {
from {
transform: rotate(0turn) translateY(60%) rotate(1turn);
}
to {
transform: rotate(1turn) translateY(60%) rotate(0turn);
}
}
.magnifying-glass {
margin: auto;
animation: circle-rotate 4s linear infinite;
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<div text="xl" font="bold" p="1">
🍲 来看看组合出的菜谱吧
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
const rStore = useRecipeStore()
const searchInput = ref<HTMLInputElement>()
onMounted(() => {
searchInput.value?.focus()
})
</script>
<template>
<div m="auto b-2" max-w="500px">
<div relative text-xs>
<div
v-if="rStore.keyword" cursor="pointer"
absolute right-2 inline-flex justify="center" items-center h="full" opacity="70"
@click="rStore.clearKeyWord()"
>
<div i-ri-close-line />
</div>
<input
id="input"
ref="searchInput"
v-model="rStore.keyword"
placeholder="关键字过滤"
aria-label="搜索关键字"
type="text"
autocomplete="false"
p="x4 y2"
w="full"
text="center"
bg="white dark:dark-800"
border="~ rounded gray-200 dark:gray-700"
class="focus:dark:gray-500"
>
<label class="hidden" for="input">快速搜索</label>
</div>
</div>
</template>

82
app/components/Switch.vue Normal file
View File

@@ -0,0 +1,82 @@
<script lang="ts" setup>
defineProps<{
strict: boolean
toggleStrict: (val: boolean) => void
}>()
</script>
<template>
<div class="inline-flex items-center justify-center" m="t-2">
<span :class="!strict && 'text-orange-600'" font="bold" m="x-1" @click="toggleStrict(false)">
模糊匹配
</span>
<label m="x-1" class="switch">
<input :modelValue="strict" type="checkbox" @update:modelValue="toggleStrict">
<span class="slider round inline-flex items-center justify-center" />
</label>
<span :class="strict && 'text-green-600'" font="bold" m="x-1" @click="toggleStrict(true)">
精准匹配
</span>
</div>
</template>
<style lang="scss">
.switch {
position: relative;
display: inline-block;
width: 48px;
height: 28px;
input {
opacity: 0;
width: 0;
height: 0;
}
}
.slider {
@apply bg-orange-600;
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
-webkit-transition: 0.4s;
transition: 0.4s;
}
$size: 20px;
.slider:before {
position: absolute;
content: '';
height: $size;
width: $size;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
@apply bg-green-600;
}
input:checked + .slider:before {
-webkit-transform: translateX($size);
-ms-transform: translateX($size);
transform: translateX($size);
}
/* Rounded sliders */
.slider.round {
border-radius: 28px;
&:before {
border-radius: 50%;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { BottomMenuItem } from '@yunlefun/vue'
const items: BottomMenuItem[] = [
{
icon: 'i-ri-home-line',
activeIcon: 'i-ri-home-fill',
title: '首页',
to: '/',
},
{
icon: 'i-ri-compass-2-line',
activeIcon: 'i-ri-compass-2-fill',
title: '吃什么',
to: '/random',
},
// {
// icon: 'i-ri-compass-2-line',
// activeIcon: 'i-ri-compass-2-fill',
// title: '吃什么',
// to: '/about',
// },
{
icon: 'i-ri-question-line',
activeIcon: 'i-ri-question-fill',
title: '帮助',
to: '/help',
},
{
icon: 'i-ri-user-line',
activeIcon: 'i-ri-user-fill',
title: '我的',
to: '/user',
},
]
const route = useRoute()
const router = useRouter()
function onClick(item: BottomMenuItem) {
// router.push(item.to || '/')
router.replace(item.to || '/')
}
</script>
<template>
<YlfBottomMenu shadow-2xl pb="$cook-bottom-menu-padding-bottom">
<YlfBottomMenuItem
v-for="item in items"
:key="item.to"
:item="item"
:active="route.path === item.to"
class="pt-3"
@click="onClick"
/>
</YlfBottomMenu>
</template>

View File

@@ -0,0 +1,32 @@
<script lang="ts" setup>
import type { SearchMode } from '~/composables/store/recipe'
const rStore = useRecipeStore()
const searchModes: {
id: SearchMode
name: string
}[] = [{
id: 'loose',
name: '模糊匹配',
}, {
id: 'strict',
name: '严格匹配',
}, {
id: 'survival',
name: '生存模式',
}]
</script>
<template>
<div>
<button
v-for="mode in searchModes" :key="mode.id" class="rounded px-2 tag"
:bg="mode.id === rStore.curMode ? 'orange-500 dark:orange-600 opacity-100' : 'orange-300 opacity-20'"
:text="mode.id === rStore.curMode ? 'orange-100' : 'orange-800 dark:orange-200'"
@click="rStore.setMode(mode.id)"
>
{{ mode.name }}
</button>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
const router = useRouter()
function back() {
router.back()
}
</script>
<template>
<YlfIconButton
icon="i-ri-arrow-left-s-line"
@click="back"
/>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
const color = useColorMode()
useHead({
meta: [{
id: 'theme-color',
name: 'theme-color',
content: () => color.value === 'dark' ? '#222222' : '#ffffff',
}],
})
function toggleDark() {
color.preference = color.value === 'dark' ? 'light' : 'dark'
}
</script>
<template>
<YlfIconButton
class="icon-btn hover:text-yellow-400 !outline-none"
text-xl
title="切换" @click="toggleDark()"
>
<div i="ri-sun-line dark:ri-moon-line" />
</YlfIconButton>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
import { db } from '~/utils/db'
const isOpen = ref(false)
function closeModal() {
isOpen.value = false
}
function openModal() {
isOpen.value = true
}
const keyword = ref('')
async function getFilterRecipes(keyword: string) {
return db.recipes.filter((recipe) => {
return recipe.name.includes(keyword)
}).toArray()
}
const filteredRecipes = computedAsync(async () => {
return await getFilterRecipes(keyword.value)
})
</script>
<template>
<YlfIconButton
absolute right-4 top-4
class="icon-btn hover:text-yellow-400 !outline-none"
text-xl
title="切换" @click="openModal"
>
<div i="ri-search-line" />
</YlfIconButton>
<TransitionRoot appear :show="isOpen" as="template">
<Dialog as="div" class="relative z-10" @close="closeModal">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/10" />
</TransitionChild>
<div class="fixed inset-0 overflow-y-auto">
<div
class="h-full flex justify-center text-center"
>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel
class="h-full max-w-xl w-full transform overflow-hidden bg-white p-4 text-left align-middle shadow-xl transition-all dark:bg-dark-600"
md="rounded-2xl"
overflow="auto"
flex="~ col"
>
<DialogTitle
as="h3"
class="flex items-center justify-center text-lg font-medium leading-6"
>
<div relative inline-flex flex="grow">
<div
i-ri-search-line
class="absolute left-3 top-2 cursor-pointer text-gray-400"
/>
<input
v-model="keyword"
type="text"
class="w-full rounded-full bg-transparent text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder-gray-400"
border="~ rounded-full gray-300 op-50 focus:border-blue-500"
placeholder="搜索菜谱"
autofocus py-2 pl-10 pr-3
>
<div
v-if="keyword" i-ri-close-line
class="absolute right-3 top-2 cursor-pointer text-gray-400"
@click="keyword = ''"
/>
</div>
<div op="70" ml-2 inline-flex cursor-pointer text-base @click="closeModal">
取消
</div>
</DialogTitle>
<div flex="~ col grow" overflow="auto" class="mt-2" text-xs>
<DishTag v-for="item, i in filteredRecipes" :key="i" :dish="item" />
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>

View File

@@ -0,0 +1,60 @@
<script lang="ts" setup>
// @ts-expect-error // Ignore this line
import VueAboutMe from 'vue-about-me'
import 'vue-about-me/style.css'
const color = useColorMode()
const isDark = computed(() => color.value === 'dark')
const copyright = {
name: 'Cook',
repo: 'YunYouJun/cook',
color: '#0078E7',
iconUrl: 'https://sponsors.yunyoujun.cn',
author: '云游君',
authorUrl: 'https://www.yunyoujun.cn',
}
const links = [
{
type: 'github',
label: 'GitHub: YunYouJun',
href: 'https://github.com/YunYouJun',
},
{
type: 'telegram',
label: 'Telegram Channel',
href: 'https://t.me/elpsycn',
},
{
type: 'weibo',
label: '微博:机智的云游君',
href: 'http://weibo.com/jizhideyunyoujun',
},
{
type: 'twitter',
label: 'Twitter: YunYouJun',
href: 'https://twitter.com/YunYouJun',
},
{
type: 'wechat',
label: '微信公众号:云游君',
href: '/wechat',
target: '_self',
},
{
type: 'bilibili',
label: '云游君Official',
href: 'https://space.bilibili.com/1579790',
},
{
type: 'blog',
label: '博客yunyoujun.cn',
href: 'http://www.yunyoujun.cn',
},
]
</script>
<template>
<VueAboutMe :is-dark="isDark" :copyright="copyright" :links="links" />
</template>

View File

@@ -0,0 +1,19 @@
<template>
<div>
<NuxtLink class="icon-btn mx-2 hover:text-orange-400" to="/help" title="帮助">
<div i-ri-question-line />
</NuxtLink>
<NuxtLink class="icon-btn mx-2 hover:text-blue-400" to="/about" title="关于">
<div i-ri-information-line />
</NuxtLink>
<a class="icon-btn mx-2 hover:text-pink-400" rel="noreferrer" href="https://space.bilibili.com/1579790" target="_blank" title="BiliBili">
<div i-ri-bilibili-line />
</a>
<a class="icon-btn hover:text-black-400 mx-2" rel="noreferrer" href="https://github.com/YunYouJun/cook" target="_blank" title="GitHub">
<div i-ri-github-line />
</a>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import type { Cookbook } from '~/types'
defineProps<{
cookbook: Cookbook
}>()
const showDetail = ref(false)
</script>
<template>
<button
class="bg-$c-bg-alt"
h-36 w-full inline-flex cursor-pointer items-center justify-center shadow
@click="showDetail = true"
>
<slot />
</button>
<CookbookDetail
v-if="showDetail"
absolute bottom-17 left-2 right-2 top-2 z-1 overflow-hidden shadow
:cookbook="cookbook"
>
<YlfIconButton
icon="i-ri-close-line"
class="absolute right-2 top-2"
@click="showDetail = false"
/>
</CookbookDetail>
</template>

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
import type { Cookbook } from '~/types'
const props = defineProps<{
cookbook: Cookbook
}>()
const recipes = ref<Cookbook['recipes']>(props.cookbook.recipes)
onMounted(async () => {
recipes.value = ((await import('../../data/recipe.json')).default) as unknown as Cookbook['recipes']
})
</script>
<template>
<div class="bg-$c-bg-alt" flex="~ col">
<h3 mt-4 font-bold>
{{ cookbook.title }}
</h3>
<sub op="90" my-3>
{{ cookbook.description }}
</sub>
<div mx-auto mt-2 p-0 border="1px" overflow-y="scroll">
<RecipeTable h="full" :recipes="recipes" />
</div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
definePageMeta({
layout: 'child',
title: '新建食谱书',
})
</script>
<template>
<NuxtLink
class="bg-$c-bg-alt"
h-36 w-full inline-flex cursor-pointer items-center justify-center shadow
to="/cookbooks/new"
>
<slot>
<div i-ri-add-line />
</slot>
</NuxtLink>
</template>

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
const app = useAppStore()
function install() {
const deferredPrompt = app.deferredPrompt
// Show the install prompt
deferredPrompt.prompt()
// Wait for the user to respond to the prompt
deferredPrompt.userChoice.then((choiceResult: any) => {
if (choiceResult.outcome === 'accepted')
// eslint-disable-next-line no-console
console.log('User accepted the install prompt')
else
// eslint-disable-next-line no-console
console.log('User dismissed the install prompt')
})
}
</script>
<template>
<Transition>
<div v-if="app.deferredPrompt" text="center" m="t-2">
<button
class="shadow"
text="white" bg="green-500" p="x-4 y-2" m="2" inline-flex
items-center justify-center rounded-md font-bold
@click="install"
>
<div i-ri-install-line mr-1 inline-flex />
<span inline-flex>安装到桌面</span>
</button>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue'
const {
offlineReady,
needRefresh,
updateServiceWorker,
} = useRegisterSW()
async function close() {
offlineReady.value = false
needRefresh.value = false
}
</script>
<template>
<div
v-if="offlineReady || needRefresh"
class="pwa-toast rounded shadow-lg transition hover:shadow-md"
border="~ stone-200 dark:stone-600"
text="center"
p="4"
m="4"
bg="white dark:dark-800"
role="alert"
>
<div class="message" m="b-4">
<span v-if="offlineReady">
可以离线使用啦
</span>
<span v-else>
更新了新的内容
</span>
</div>
<button
v-if="needRefresh"
m="x-2" p="x-4 y-1" text="sm white"
class="rounded shadow transition active:shadow-md"
bg="green-500 active:green-600"
@click="updateServiceWorker()"
>
更新
</button>
<button
m="x-2" p="x-4 y-1" text="sm"
class="rounded shadow transition active:shadow-md"
border="~ stone-200 dark:stone-600"
bg="active:(white opacity-20)"
@click="close"
>
关闭
</button>
</div>
</template>
<style>
.pwa-toast {
position: fixed;
right: 0;
bottom: 0;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import pkg from '~/../package.json'
const commitSha = (import.meta.env.VITE_COMMIT_REF || '').slice(0, 7)
const now = import.meta.env.VITE_APP_BUILD_TIME
const buildDate = (new Date(Number.parseInt(now))).toLocaleDateString()
</script>
<template>
<div v-if="commitSha && buildDate" mb-2 text-sm>
<span>
当前版本 v{{ pkg.version }}{{ buildDate }}:
</span>
<span>
<a border="b-1 dashed" :href="`https://github.com/YunYouJun/cook/commit/${commitSha}`" target="_blank" alt="Cook | GitHub Commit">
{{ commitSha }}
</a>
</span>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<template>
<div text="center sm" my-3>
<CurrentVersion />
<div flex="~" items-center justify-center gap="2">
<a
href="https://github.com/YunYouJun/cook" target="_blank"
class="inline-flex items-center justify-center"
>
<div i-ri-github-line mr-1 />
<span>Code</span>
</a>
by
<a
href="https://www.bilibili.com/opus/649847454294868008" target="_blank"
class="inline-flex items-center justify-center"
>
<div i-ri-bilibili-line mr-1 class="text-pink-400" />
<span>云游君</span>
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { Recipes } from '~/types'
defineProps<{
recipes: Recipes
}>()
</script>
<template>
<table
class="recipe-table bg-$c-bg"
overflow="auto" h="full"
>
<thead>
<tr>
<th />
<th>
名称
</th>
<th>
工具
</th>
<th>
材料
</th>
</tr>
</thead>
<tbody>
<RecipeTableItem
v-for="(recipe, i) in recipes"
:key="recipe.name"
:index="i" :recipe="recipe"
/>
</tbody>
</table>
</template>
<style lang="scss">
.recipe-table {
font-size: 0.8rem;
}
tr,
th,
td {
border: 1px solid black;
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import type { RecipeItem } from '~/types'
defineProps<{
index: number
recipe: RecipeItem
}>()
</script>
<template>
<tr>
<td>
{{ index }}
</td>
<td>
<a
class="text-blue-500" font-bold
:href="recipe.link || `https://www.bilibili.com/video/${recipe.bv}`"
target="_blank"
>
{{ recipe.name }}
</a>
</td>
<td>
{{ recipe.tools.join('、') }}
</td>
<td>
{{ recipe.stuff.join('、') }}
</td>
</tr>
</template>

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import type { DbRecipeItem } from '~/utils/db'
import { tools } from '~/data/food'
import type { RecipeItem } from '~/types'
import { getEmojisFromStuff } from '~/utils'
import { recipeHistories } from '~/composables/store/history'
const props = defineProps<{
dish: RecipeItem | DbRecipeItem
}>()
const gtm = useGtm()
function triggerGtm(dish: RecipeItem) {
recipeHistories.value.push({
recipe: dish,
time: Date.now(),
})
gtm?.trackEvent({
event: 'click',
category: `dish_${dish.name}`,
action: 'click_recipe',
label: '跳转菜谱',
})
gtm?.trackEvent({
event: 'click_dish',
action: dish.name,
})
}
const dishLabel = computed(() => {
const emojis = getEmojisFromStuff(props.dish.stuff)
return `${props.dish.tags?.includes('杂烩') ? '🍲' : emojis.join(' ')} ${props.dish.name}`
})
</script>
<template>
<a
:href="dish.link || `https://www.bilibili.com/video/${dish.bv}`" target="_blank" class="dish-tag rounded tag" p="x-2"
border="~ blue-200 dark:blue-800"
bg="blue-300 opacity-20"
@click="triggerGtm(dish)"
>
<span m="r-1" text="sm blue-700 dark:blue-200">
{{ dishLabel }}
</span>
<template v-for="tool, i in tools">
<span v-if="dish.tools?.includes(tool.name)" :key="i" :class="tool.icon" />
</template>
</a>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
defineProps<{
active: boolean
}>()
</script>
<template>
<span
class="meat-tag rounded tag" p="x-2"
border="~ red-200 dark:red-800"
:bg="active ? 'red-500 opacity-90' : 'red-300 opacity-20'"
:text="active ? 'red-100' : 'red-800 dark:red-200'"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
defineProps<{
active: boolean
}>()
</script>
<template>
<span
class="rounded tag" p="x-2" border="~ yellow-200 dark:yellow-800"
:bg="active ? 'yellow-500 dark:yellow-600 opacity-100' : 'yellow-300 opacity-20'"
:text="active ? 'yellow-100' : 'yellow-800 dark:yellow-200'"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
defineProps<{
active: boolean
}>()
</script>
<template>
<span
class="rounded tag" p="x-2"
border="~ stone-200 dark:stone-600"
:bg="active ? 'stone-600 opacity-100' : 'stone-300 opacity-5'"
:text="active ? 'stone-100' : 'stone-800 dark:stone-200'"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
defineProps<{
active: boolean
}>()
</script>
<template>
<span
class="vegetable-tag rounded tag" p="x-2"
border="~ green-200 dark:green-800"
:bg="active ? 'green-600 opacity-90' : 'green-300 opacity-20'"
:text="active ? 'green-100' : 'green-800 dark:green-200'"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,24 @@
<template>
<div class="ylf-form" flex="~ col" rounded-md>
<slot />
</div>
</template>
<style lang="scss">
.ylf-form {
background-color: var(--ylf-c-bg-alt);
border: 1px solid var(--ylf-c-border);
margin: 10px 0;
overflow: hidden;
.ylf-form-item {
border-bottom: 1px solid var(--ylf-c-border);
&:last-child {
border-bottom: none;
}
}
}
</style>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { NuxtLink } from '#components'
defineProps<{
icon?: string
label?: string
/**
* Router link
*/
to?: string
}>()
</script>
<template>
<component
:is="to ? NuxtLink : 'div'"
:to="to"
class="ylf-form-item"
w-full flex cursor-pointer items-center justify-between p-2
hover:bg-gray-100
dark:hover:bg-dark-400
>
<div v-if="label" class="text-sm" inline-flex items-center justify-center>
<div v-if="icon" :class="icon" mr-2 inline-flex />
<span>{{ label }}</span>
</div>
<div inline-flex>
<slot>
<div v-if="to" i-ri-arrow-right-s-line />
</slot>
</div>
</component>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
defineProps<{
icon?: string
}>()
</script>
<template>
<button
class="ylf-icon-button hover:(bg-blue-300 bg-opacity-20)"
h-10 w-10 inline-flex items-center justify-center rounded-full
>
<slot>
<div v-if="icon" text-xl :class="icon" />
</slot>
</button>
</template>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
defineProps<{
icon: string
label: string
to: string
}>()
</script>
<template>
<NuxtLink
:to="to"
flex="~ col"
border="~ solid dark:$ylf-c-border"
bg="$ylf-c-bg-alt"
class="inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium decoration-none hover-bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 dark:hover:bg-dark-400"
>
<div :class="icon" inline-flex text-lg />
<div mt-2 inline-flex text-xs>
{{ label }}
</div>
</NuxtLink>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Switch } from '@headlessui/vue'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits(['update:modelValue'])
function updateModelValue(value: boolean) {
emit('update:modelValue', value)
}
</script>
<template>
<Switch
:model-value="modelValue"
:class="modelValue ? 'bg-blue-600' : 'bg-gray'"
class="relative h-6 w-11 inline-flex shrink-0 cursor-pointer border-2 border-transparent rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
@update:model-value="updateModelValue"
>
<span class="sr-only">Use setting</span>
<span
aria-hidden="true"
:class="modelValue ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
/>
</Switch>
</template>