mirror of
https://github.com/ArthurDanjou/arthome.git
synced 2026-01-14 12:14:33 +01:00
Working
This commit is contained in:
@@ -19,5 +19,12 @@ export default defineAppConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
textarea: {
|
||||
color: {
|
||||
white: {
|
||||
outline: 'shadow-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-white ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:ring-2 focus:ring-zinc-500 dark:focus:ring-zinc-500',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ useHead({
|
||||
title: 'ArtHome by Arthur Danjou',
|
||||
})
|
||||
|
||||
const { loggedIn } = useUserSession()
|
||||
const { loggedIn } = await useUserSession()
|
||||
|
||||
watch(loggedIn, async () => {
|
||||
if (!loggedIn.value) {
|
||||
|
||||
22
app/components/App/Avatar.vue
Normal file
22
app/components/App/Avatar.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
src: string | null
|
||||
}>()
|
||||
|
||||
const src = computed(() => {
|
||||
if (!props.src) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.src.startsWith('http') || props.src.startsWith('data')) {
|
||||
return props.src
|
||||
}
|
||||
|
||||
// Prefix the image path with the images folder
|
||||
return `images/${props.src}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAvatar :src />
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ defineProps<{
|
||||
}>()
|
||||
defineEmits(['createTab'])
|
||||
|
||||
const { canCreateTabInCategory } = await useUserLimit()
|
||||
const { canCreateTabInCategory } = await useUserLimits()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
10
app/components/App/Footer.vue
Normal file
10
app/components/App/Footer.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// todo: implement the footer
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
const colorMode = useColorMode()
|
||||
const { user, loggedIn, clear } = useUserSession()
|
||||
const { user, loggedIn, clear } = await useUserSession()
|
||||
const isSettingsOpen = ref(false)
|
||||
|
||||
const isDark = computed(() => colorMode.preference === 'dark')
|
||||
@@ -33,20 +33,21 @@ const items = [
|
||||
}],
|
||||
]
|
||||
|
||||
function toggleColorMode() {
|
||||
colorMode.preference = isDark.value ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await clear()
|
||||
navigateTo('/login')
|
||||
await navigateTo('/')
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function toggleColorMode() {
|
||||
colorMode.preference = isDark.value ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
defineShortcuts({
|
||||
t: () => toggleColorMode(),
|
||||
s: () => isSettingsOpen.value = !isSettingsOpen.value,
|
||||
l: async () => await logout(),
|
||||
h: () => navigateTo('/'),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -69,7 +70,7 @@ defineShortcuts({
|
||||
:ui="{ item: { disabled: 'cursor-text select-text' } }"
|
||||
:popper="{ placement: 'bottom-end' }"
|
||||
>
|
||||
<UAvatar :src="user.avatar" />
|
||||
<AppAvatar :src="user.avatar" />
|
||||
|
||||
<template #account>
|
||||
<div class="text-left">
|
||||
@@ -95,7 +96,7 @@ defineShortcuts({
|
||||
</template>
|
||||
|
||||
<template #logout="{ item }">
|
||||
<div class="w-full flex justify-between items-center" @click="logout()">
|
||||
<div class="w-full flex justify-between items-center" @click="logout">
|
||||
<div class="flex gap-2 items-center">
|
||||
<UIcon :name="item.icon" class="flex-shrink-0 h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
@@ -123,13 +124,23 @@ defineShortcuts({
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<div class="space-y-12">
|
||||
<div class="space-y-12 overflow-auto">
|
||||
<div>
|
||||
Delete account
|
||||
Change user details
|
||||
<AppUserSettingsForm :user="user" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
color="red"
|
||||
variant="solid"
|
||||
icon="i-ph:trash-duotone"
|
||||
block
|
||||
>
|
||||
Delete account
|
||||
</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
</USlideover>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabType } from '~~/types/types'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
tab: TabType
|
||||
editMode: boolean
|
||||
}>()
|
||||
|
||||
const { setTabPrimary } = await useTabs()
|
||||
@@ -46,74 +47,106 @@ function openDeleteTabModal(tab: TabType) {
|
||||
currentDeleteTab.value = tab
|
||||
deleteTabModal.value = true
|
||||
}
|
||||
|
||||
function visitLink() {
|
||||
if (!props.editMode) {
|
||||
window.open(props.tab.link, '_blank')
|
||||
// add view count
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ULink
|
||||
:to="tab.link"
|
||||
class="relative"
|
||||
target="_blank"
|
||||
<UCard
|
||||
:ui="{
|
||||
body: { base: 'h-full relative z-20' },
|
||||
background: `h-full duration-300 bg-white dark:bg-gray-900 ${editMode ? '' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}`,
|
||||
}"
|
||||
:class="editMode ? 'animate-wiggle' : 'cursor-pointer'"
|
||||
@click.prevent="visitLink"
|
||||
>
|
||||
<div v-show="tab.primary" class="absolute flex h-3 w-3 -left-1 -top-1">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" :class="`bg-${tab.color}-400`" />
|
||||
<span class="relative inline-flex rounded-full h-3 w-3" :class="`bg-${tab.color}-400`" />
|
||||
</div>
|
||||
<UCard
|
||||
:ui="{ body: { base: 'h-full' }, background: 'h-full duration-300 bg-white hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-800' }"
|
||||
>
|
||||
<div class="flex items-center justify-between h-full">
|
||||
<div class="flex gap-4 items-center h-full">
|
||||
<UBadge :color="tab.color" class="p-2" variant="soft">
|
||||
<UIcon :name="tab.icon" size="32" />
|
||||
</UBadge>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p :class="`text-${tab.color}-400`" class="text-xl font-medium truncate">
|
||||
{{ tab.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between h-full">
|
||||
<div class="flex gap-4 items-center h-full">
|
||||
<UBadge :color="tab.color" class="p-2" variant="soft">
|
||||
<UIcon :name="tab.icon" size="32" />
|
||||
</UBadge>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p :class="`text-${tab.color}-400`" class="text-xl font-medium truncate">
|
||||
{{ tab.name }}
|
||||
</p>
|
||||
</div>
|
||||
<UDropdown
|
||||
:items="items"
|
||||
:popper="{ placement: 'bottom-end', arrow: true }"
|
||||
:ui="{ container: 'z-50 group', width: 'w-40', shadow: 'shadow-2xl' }"
|
||||
>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
:padded="false"
|
||||
size="sm"
|
||||
icon="i-ph:dots-three-outline-duotone"
|
||||
/>
|
||||
|
||||
<template #item="{ item }">
|
||||
<div class="w-full flex items-center justify-between" @click.prevent="item.click(tab)">
|
||||
<span
|
||||
class="truncate"
|
||||
:class="`text-${item.color}-500`"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
class="flex-shrink-0 h-4 w-4 ms-auto"
|
||||
:class="`text-${item.color}-500`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UDropdown>
|
||||
</div>
|
||||
</UCard>
|
||||
<ModalUpdateTab
|
||||
v-if="currentUpdateTab"
|
||||
v-model="updateTabModal"
|
||||
:tab="currentUpdateTab"
|
||||
@close-modal="updateTabModal = false"
|
||||
/>
|
||||
<ModalDeleteTab
|
||||
v-if="currentDeleteTab"
|
||||
v-model="deleteTabModal"
|
||||
:tab="currentDeleteTab"
|
||||
@close-modal="deleteTabModal = false"
|
||||
/>
|
||||
</ULink>
|
||||
<UDropdown
|
||||
:items="items"
|
||||
:popper="{ placement: 'bottom-end', arrow: true }"
|
||||
:ui="{ container: 'z-50 group', width: 'w-40', shadow: 'shadow-2xl', wrapper: 'absolute inline-flex -top-3 -right-3' }"
|
||||
>
|
||||
<UButton
|
||||
v-show="editMode"
|
||||
color="gray"
|
||||
variant="solid"
|
||||
size="md"
|
||||
:padded="false"
|
||||
:ui="{ rounded: 'rounded-full p-1' }"
|
||||
icon="i-ph:dots-three-outline-duotone"
|
||||
/>
|
||||
|
||||
<template #item="{ item }">
|
||||
<div class="w-full flex items-center justify-between" @click.prevent="item.click(tab)">
|
||||
<span
|
||||
class="truncate"
|
||||
:class="`text-${item.color}-500`"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
class="flex-shrink-0 h-4 w-4 ms-auto"
|
||||
:class="`text-${item.color}-500`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UDropdown>
|
||||
</div>
|
||||
</UCard>
|
||||
<ModalUpdateTab
|
||||
v-if="currentUpdateTab"
|
||||
v-model="updateTabModal"
|
||||
:tab="currentUpdateTab"
|
||||
@close-modal="updateTabModal = false"
|
||||
/>
|
||||
<ModalDeleteTab
|
||||
v-if="currentDeleteTab"
|
||||
v-model="deleteTabModal"
|
||||
:tab="currentDeleteTab"
|
||||
@close-modal="deleteTabModal = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes wiggle {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-wiggle {
|
||||
animation: wiggle .4s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
115
app/components/App/UserSettingsForm.vue
Normal file
115
app/components/App/UserSettingsForm.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import type { UpdateUserSchemaType } from '~~/types/types'
|
||||
import { UpdateUserSchema, locales } from '~~/types/types'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
import type { UserSession } from '#auth-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
user: UserSession
|
||||
}>()
|
||||
|
||||
const state = reactive({
|
||||
name: undefined,
|
||||
username: undefined,
|
||||
email: undefined,
|
||||
private: undefined,
|
||||
description: undefined,
|
||||
language: locales[0],
|
||||
location: undefined,
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
state.name = props.user.name
|
||||
state.username = props.user.username
|
||||
state.private = props.user.private
|
||||
state.description = props.user.description
|
||||
state.language = locales.find(locale => locale.locale === props.user.language).locale
|
||||
state.location = props.user.location
|
||||
state.email = props.user.email
|
||||
})
|
||||
|
||||
async function handleUpdate(event: FormSubmitEvent<UpdateUserSchemaType>) {
|
||||
try {
|
||||
await useRequestFetch()(`/api/users/me`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
username: event.data.username,
|
||||
name: event.data.name,
|
||||
description: event.data.description,
|
||||
location: event.data.location,
|
||||
language: event.data.language,
|
||||
private: event.data.private,
|
||||
}),
|
||||
})
|
||||
useSuccessToast('Profile successfully updated!')
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Profile update failed!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
const { deleteAvatar, uploadAvatar } = await useUser()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm :schema="UpdateUserSchema" :state="state" class="space-y-4 p-1" @submit="handleUpdate">
|
||||
<UFormGroup label="Username" name="username">
|
||||
<UInput v-model="state.username" type="text" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Name" name="name">
|
||||
<UInput v-model="state.name" type="text" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Avatar" name="avatar">
|
||||
<UInput type="file" size="sm" accept="image/*" hidden @change="uploadAvatar" />
|
||||
</UFormGroup>
|
||||
|
||||
<UButton
|
||||
v-if="user?.avatar"
|
||||
variant="outline"
|
||||
color="red"
|
||||
label="Delete avatar"
|
||||
size="xs"
|
||||
@click.prevent="deleteAvatar"
|
||||
/>
|
||||
|
||||
<UFormGroup label="Email" name="email">
|
||||
<UInput v-model="state.email" type="text" disabled />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Description" name="description">
|
||||
<UTextarea v-model="state.description" autoresize :rows="2" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Language" name="language">
|
||||
<USelect v-model="state.language" :options="locales" option-attribute="label" value-attribute="locale" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Location" name="location">
|
||||
<UInput v-model="state.location" type="text" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Page private" name="private" :description="state.private ? 'Your page is private' : 'Your page is public'">
|
||||
<UToggle
|
||||
v-model="state.private"
|
||||
on-icon="i-ph:lock-key-duotone"
|
||||
off-icon="i-ph:users-four-duotone"
|
||||
:model-value="state.private"
|
||||
size="lg"
|
||||
color="red"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
block
|
||||
color="gray"
|
||||
label="Update Profile"
|
||||
/>
|
||||
</UForm>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import { COLORS, CreateCategorySchema } from '~~/types/types'
|
||||
|
||||
const emit = defineEmits(['closeModal'])
|
||||
const { createCategory } = await useCategories()
|
||||
const { refreshUserLimits, canCreateCategory } = await useUserLimits()
|
||||
const state = reactive({
|
||||
name: undefined,
|
||||
icon: undefined,
|
||||
@@ -14,6 +15,12 @@ const state = reactive({
|
||||
|
||||
async function handleCreate(event: FormSubmitEvent<CreateCategorySchemaType>) {
|
||||
await createCategory(event.data)
|
||||
await refreshUserLimits()
|
||||
|
||||
if (!canCreateCategory()) {
|
||||
useErrorToast('You have reach the limit of categories', 'Subscribe to a paid plan to create more categories')
|
||||
}
|
||||
|
||||
emit('closeModal')
|
||||
state.color = COLORS[0]
|
||||
state.nameVisible = true
|
||||
@@ -34,7 +41,7 @@ const { loading, search } = useIcons()
|
||||
</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="p-1"
|
||||
@click="$emit('closeModal')"
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits(['closeModal'])
|
||||
const { createTab } = await useTabs()
|
||||
const { refreshUserLimits, canCreateTabInCategory } = await useUserLimits()
|
||||
const { categories } = await useCategories()
|
||||
|
||||
const state = reactive({
|
||||
@@ -24,7 +25,16 @@ watchEffect(() => {
|
||||
})
|
||||
|
||||
async function handleCreate(event: FormSubmitEvent<CreateTabSchemaType>) {
|
||||
await createTab(event.data)
|
||||
await createTab({
|
||||
primary: Boolean(event.data.primary),
|
||||
...event.data,
|
||||
})
|
||||
await refreshUserLimits()
|
||||
|
||||
if (!canCreateTabInCategory(state.categoryId)) {
|
||||
useErrorToast('You have reach the limit of tabs in this category', 'Subscribe to a paid plan to create more tabs')
|
||||
}
|
||||
|
||||
emit('closeModal')
|
||||
state.name = undefined
|
||||
state.icon = undefined
|
||||
@@ -47,7 +57,7 @@ const { loading, search } = useIcons()
|
||||
</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="p-1"
|
||||
@click="$emit('closeModal')"
|
||||
|
||||
18
app/components/Modal/DeleteAccount.vue
Normal file
18
app/components/Modal/DeleteAccount.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal>
|
||||
avatar
|
||||
Warning: This will permanently delete your account, all your categories, and all your tabs.
|
||||
---
|
||||
To verify, type confirm delete account below
|
||||
input
|
||||
confirm
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -7,9 +7,11 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits(['closeModal'])
|
||||
const { deleteCategory } = await useCategories()
|
||||
const { refreshUserLimits } = await useUserLimits()
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteCategory(props.category.id)
|
||||
await refreshUserLimits()
|
||||
emit('closeModal')
|
||||
}
|
||||
|
||||
@@ -28,7 +30,7 @@ defineShortcuts({
|
||||
</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="p-1"
|
||||
@click="$emit('closeModal')"
|
||||
|
||||
@@ -7,9 +7,11 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits(['closeModal'])
|
||||
const { deleteTab } = await useTabs()
|
||||
const { refreshUserLimits } = await useUserLimits()
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteTab(props.tab.id)
|
||||
await refreshUserLimits()
|
||||
emit('closeModal')
|
||||
}
|
||||
|
||||
@@ -28,7 +30,7 @@ defineShortcuts({
|
||||
</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="p-1"
|
||||
@click="$emit('closeModal')"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { COLORS, type CategoryType, UpdateCategorySchema } from '~~/types/types'
|
||||
import type { CategoryType, UpdateCategorySchemaType } from '~~/types/types'
|
||||
import { COLORS, UpdateCategorySchema } from '~~/types/types'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,7 +25,7 @@ watchEffect(() => {
|
||||
state.nameVisible = props.category?.nameVisible
|
||||
})
|
||||
|
||||
async function handleUpdate(event: FormSubmitEvent<UpdateCategorySchema>) {
|
||||
async function handleUpdate(event: FormSubmitEvent<UpdateCategorySchemaType>) {
|
||||
await updateCategory({
|
||||
id: props.category!.id,
|
||||
...event.data,
|
||||
@@ -43,7 +44,7 @@ async function handleUpdate(event: FormSubmitEvent<UpdateCategorySchema>) {
|
||||
</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="p-1"
|
||||
@click="$emit('closeModal')"
|
||||
|
||||
@@ -18,6 +18,7 @@ const state = reactive({
|
||||
color: COLORS[0],
|
||||
primary: undefined,
|
||||
categoryId: undefined,
|
||||
link: undefined,
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -26,6 +27,7 @@ watchEffect(() => {
|
||||
state.color = props.tab?.color
|
||||
state.primary = props.tab?.primary
|
||||
state.categoryId = props.tab?.categoryId
|
||||
state.link = props.tab?.link
|
||||
})
|
||||
|
||||
async function handleUpdate(event: FormSubmitEvent<UpdateTabSchemaType>) {
|
||||
@@ -44,11 +46,11 @@ async function handleUpdate(event: FormSubmitEvent<UpdateTabSchemaType>) {
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">
|
||||
Update category '{{ tab.name }}'
|
||||
Update tab '{{ tab.name }}'
|
||||
</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
class="p-1"
|
||||
@click="$emit('closeModal')"
|
||||
@@ -97,6 +99,10 @@ async function handleUpdate(event: FormSubmitEvent<UpdateTabSchemaType>) {
|
||||
<USelect v-model="state.categoryId" :options="categories" option-attribute="name" value-attribute="id" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Link " name="link">
|
||||
<UInput v-model="state.link" type="text" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup>
|
||||
<UCheckbox v-model="state.primary" :color="state.color" label="Is the category primary?" />
|
||||
</UFormGroup>
|
||||
|
||||
@@ -4,42 +4,49 @@ export async function useCategories() {
|
||||
const { data: categories, refresh }
|
||||
= await useAsyncData<CategoryType[]>(async () => await useRequestFetch()('/api/categories'))
|
||||
|
||||
async function getCategory(id: number): CategoryType {
|
||||
return categories.data.value.find(category => category.id === id)
|
||||
}
|
||||
|
||||
async function createCategory(category: CreateCategorySchema) {
|
||||
await $fetch('/api/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(category),
|
||||
})
|
||||
.catch(error => useErrorToast('Category creation failed!', `Error: ${error}`))
|
||||
await refresh()
|
||||
await useSuccessToast('Category successfully created!')
|
||||
try {
|
||||
await useRequestFetch()('/api/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(category),
|
||||
})
|
||||
await refresh()
|
||||
await useSuccessToast('Category successfully created!', category.color)
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Category creation failed!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCategory(category: UpdateCategorySchema & { id: number }) {
|
||||
await $fetch(`/api/categories/${category.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(category),
|
||||
})
|
||||
.catch(error => useErrorToast('Category update failed!', `Error: ${error}`))
|
||||
await refresh()
|
||||
await useSuccessToast('Category successfully updated!')
|
||||
try {
|
||||
await $fetch(`/api/categories/${category.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(category),
|
||||
})
|
||||
await refresh()
|
||||
await useSuccessToast('Category successfully updated!')
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Category update failed!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory(id: number) {
|
||||
await $fetch(`/api/categories/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.catch(error => useErrorToast('Category deletion failed!', `Error: ${error}`))
|
||||
await refresh()
|
||||
await useSuccessToast('Category successfully deleted!')
|
||||
try {
|
||||
await $fetch(`/api/categories/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
await useSuccessToast('Category successfully deleted!')
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Category deletion failed!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
getCategory,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
|
||||
@@ -9,51 +9,61 @@ export async function useTabs() {
|
||||
}
|
||||
|
||||
async function createTab(tab: CreateTabSchema) {
|
||||
await $fetch('/api/tabs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(tab),
|
||||
})
|
||||
.then(async () => {
|
||||
await refresh()
|
||||
useSuccessToast('Tab successfully created!')
|
||||
try {
|
||||
await $fetch('/api/tabs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(tab),
|
||||
})
|
||||
.catch(error => useErrorToast('Tab creation failed!', `Error: ${error}`))
|
||||
await refresh()
|
||||
useSuccessToast('Tab successfully created!', tab.color)
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Tab creation failed!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTab(tab: UpdateTabSchema) {
|
||||
await $fetch(`/api/tabs/${tab.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(tab),
|
||||
})
|
||||
.then(async () => {
|
||||
await refresh()
|
||||
useSuccessToast('Tab successfully updated!')
|
||||
try {
|
||||
await $fetch(`/api/tabs/${tab.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(tab),
|
||||
})
|
||||
.catch(error => useErrorToast('Tab update failed!', `Error: ${error}`))
|
||||
await refresh()
|
||||
useSuccessToast('Tab successfully updated!')
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Tab update failed!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function setTabPrimary(tab, primary: boolean) {
|
||||
await $fetch(`/api/tabs/${tab.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
primary,
|
||||
categoryId: tab.categoryId,
|
||||
}),
|
||||
})
|
||||
.then(async () => {
|
||||
await refresh()
|
||||
useSuccessToast('Tab favorite toggled with success!')
|
||||
try {
|
||||
await $fetch(`/api/tabs/${tab.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
primary,
|
||||
categoryId: tab.categoryId,
|
||||
}),
|
||||
})
|
||||
.catch(error => useErrorToast('Cannot toggle Tab favorite!', `Error: ${error}`))
|
||||
await refresh()
|
||||
useSuccessToast(`Tab ${tab.name} ${primary ? 'set as favorite' : 'unset as favorite'}!`, 'yellow')
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Cannot toggle favorite state for tab!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTab(id: number) {
|
||||
await $fetch(`/api/tabs/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.catch(error => useErrorToast('Tab deletion failed!', `Error: ${error}`))
|
||||
await refresh()
|
||||
useSuccessToast('Tab successfully deleted!')
|
||||
try {
|
||||
await $fetch(`/api/tabs/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
useSuccessToast('Tab successfully deleted!')
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('Tab deletion failed!', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export function useSuccessToast(title: string, description?: string) {
|
||||
export function useSuccessToast(title: string, color?: string, description?: string) {
|
||||
const toast = useToast()
|
||||
|
||||
toast.add({
|
||||
title,
|
||||
description,
|
||||
color: 'green',
|
||||
color: color || 'green',
|
||||
icon: 'i-ph:check-circle-duotone',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export async function useUserLimit() {
|
||||
const { user } = useUserSession()
|
||||
const { categories } = await useCategories()
|
||||
const { tabs } = await useTabs()
|
||||
|
||||
const hasPaidPlan = computed(() => user.value.subscription !== 'free')
|
||||
|
||||
function canCreateCategory() {
|
||||
if (hasPaidPlan.value)
|
||||
return true
|
||||
return categories.value.length < 3
|
||||
}
|
||||
|
||||
function canCreateTabInCategory(categoryId: number): boolean {
|
||||
if (hasPaidPlan.value)
|
||||
return true
|
||||
return tabs.filter(tab => tab.categoryId === categoryId).length < 5
|
||||
}
|
||||
|
||||
return {
|
||||
hasPaidPlan,
|
||||
userLimits,
|
||||
canCreateCategory,
|
||||
canCreateTabInCategory,
|
||||
}
|
||||
}
|
||||
28
app/composables/user-limits.ts
Normal file
28
app/composables/user-limits.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const MAX_CATEGORIES = 3
|
||||
const MAX_TABS_PER_CATEGORY = 6
|
||||
|
||||
export async function useUserLimits() {
|
||||
const { user } = useUserSession()
|
||||
const { data: userLimits, refresh: refreshUserLimits } = await useFetch('/api/users/limits')
|
||||
|
||||
const hasPaidPlan = computed(() => user.value.subscription !== 'free')
|
||||
|
||||
function canCreateCategory() {
|
||||
if (hasPaidPlan.value)
|
||||
return true
|
||||
return userLimits.value.categories.length < MAX_CATEGORIES
|
||||
}
|
||||
|
||||
function canCreateTabInCategory(categoryId: number): boolean {
|
||||
if (hasPaidPlan.value)
|
||||
return true
|
||||
return userLimits.value.categories.find(category => category.id === categoryId).tabs.length < MAX_TABS_PER_CATEGORY
|
||||
}
|
||||
|
||||
return {
|
||||
hasPaidPlan,
|
||||
canCreateCategory,
|
||||
canCreateTabInCategory,
|
||||
refreshUserLimits,
|
||||
}
|
||||
}
|
||||
43
app/composables/users.ts
Normal file
43
app/composables/users.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export async function useUser() {
|
||||
const { fetch } = useUserSession()
|
||||
|
||||
async function deleteAvatar() {
|
||||
try {
|
||||
await useRequestFetch()('/api/users/avatars', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
useSuccessToast('Avatar successfully deleted!')
|
||||
await fetch()
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('An error occurred while deleting your avatar', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAvatar(event: Event) {
|
||||
const file = event[0] as File
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
await useRequestFetch()('/api/users/avatars', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
await fetch()
|
||||
useSuccessToast('Avatar successfully uploaded!')
|
||||
}
|
||||
catch (error) {
|
||||
useErrorToast('An error occurred while uploading your avatar', error as string)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deleteAvatar,
|
||||
uploadAvatar,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const { loggedIn } = useUserSession()
|
||||
const { loggedIn, user } = await useUserSession()
|
||||
|
||||
if (loggedIn.value) {
|
||||
return navigateTo('/')
|
||||
return navigateTo(`/${user.value.username.toLowerCase()}`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const date = ref<Date>(new Date())
|
||||
onMounted(() => {
|
||||
setInterval(() => date.value = new Date(), 1000)
|
||||
})
|
||||
|
||||
const { user } = useUserSession()
|
||||
|
||||
onMounted(async () => {
|
||||
if (user.value.username.toLowerCase() === router.currentRoute.value.params.user.toLowerCase()) {
|
||||
await navigateTo('/')
|
||||
}
|
||||
})
|
||||
const { data: userDetails } = await useAsyncData(async () => await $fetch(`/api/users/${router.currentRoute.value.params.user}`))
|
||||
onMounted(() => {
|
||||
if (userDetails.value.message) {
|
||||
useErrorToast(userDetails.value.message, 'Look for another user.')
|
||||
}
|
||||
if (userDetails.value.private) {
|
||||
useErrorToast('This user\'s profile is private.', 'Look for another user.')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
{{ router.currentRoute.value.params.user }}
|
||||
</section>
|
||||
<main class="my-12">
|
||||
<div v-if="date" class="flex flex-col items-center mb-12">
|
||||
<h1 class="text-6xl md:text-9xl font-bold">
|
||||
{{ useDateFormat(date, 'HH') }}
|
||||
<span class="animate-pulse">:</span>
|
||||
{{ useDateFormat(date, 'mm') }}
|
||||
</h1>
|
||||
<h1 class="text-2xl md:text-5xl">
|
||||
{{ useDateFormat(date, 'dddd D MMMM YYYY', { locales: userDetails.locale ? userDetails.language : user.locale }) }}
|
||||
</h1>
|
||||
</div>
|
||||
<div v-if="userDetails.message || userDetails.private" class="text-center mt-24 space-y-4">
|
||||
<div
|
||||
class="flex items-center justify-center gap-2 text-3xl"
|
||||
:class="userDetails.message ? 'text-amber-500 dark:text-amber-400' : 'text-red-500 dark:text-red-400'"
|
||||
>
|
||||
<UIcon name="i-ph:warning-circle-duotone" />
|
||||
<p>
|
||||
{{ userDetails.message ? userDetails.message : 'This user\'s profile is private.' }}
|
||||
</p>
|
||||
</div>
|
||||
<h1 class="text-lg italic text-neutral-600 dark:text-neutral-400">
|
||||
Please look for another user.
|
||||
</h1>
|
||||
<UButton
|
||||
label="Go to your page"
|
||||
:color="userDetails.message ? 'amber' : 'red'"
|
||||
size="xl"
|
||||
icon="i-ph:house-line-duotone"
|
||||
variant="outline"
|
||||
to="/"
|
||||
/>
|
||||
</div>
|
||||
<section v-else>
|
||||
<div v-if="userDetails.categories.length > 0" class="space-y-12">
|
||||
<div
|
||||
v-for="category in userDetails.categories"
|
||||
:key="category.id"
|
||||
>
|
||||
<div v-if="category" class="flex items-center mb-4" :class="category.nameVisible ? 'justify-between' : 'justify-end'">
|
||||
<div v-if="category.nameVisible" class="flex items-center gap-2 mb-4" :class="`text-${category.color}-500`">
|
||||
<UIcon :name="category.icon" size="28" />
|
||||
<h1 class="font-bold text-2xl">
|
||||
{{ category.name }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="userDetails.categories.filter(tab => tab.categoryId === category.id).length > 0"
|
||||
class="grid grid-cols-1 auto-rows-auto sm:grid-cols-3 gap-4"
|
||||
>
|
||||
{{ userDetails.categories.filter(tab => tab.categoryId === category.id) }}
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
<UIcon name="i-ph:empty-duotone" size="16" />
|
||||
<h1 class="text-sm font-medium">
|
||||
The category is empty.
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
<UIcon name="i-ph:empty-duotone" size="20" />
|
||||
<h1 class="text-lg font-medium">
|
||||
This user doesn't have any categories.
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import type { CategoryType } from '~~/types/types'
|
||||
|
||||
definePageMeta({
|
||||
@@ -10,10 +10,10 @@ onMounted(() => {
|
||||
setInterval(() => date.value = new Date(), 1000)
|
||||
})
|
||||
|
||||
const { user } = useUserSession()
|
||||
const { user } = await useUserSession()
|
||||
const { categories } = await useCategories()
|
||||
const { getTabsForCategory } = await useTabs()
|
||||
const { canCreateCategory } = await useUserLimit()
|
||||
const { canCreateCategory } = await useUserLimits()
|
||||
|
||||
// Modals
|
||||
const createCategoryModal = ref(false)
|
||||
@@ -42,14 +42,23 @@ function openCreateTab(category: CategoryType) {
|
||||
createTabModal.value = true
|
||||
}
|
||||
|
||||
// Edit Tabs
|
||||
const currentEditCategory = ref<CategoryType | null>(null)
|
||||
|
||||
// DropDown Items
|
||||
const items = [[
|
||||
{
|
||||
label: 'Edit',
|
||||
label: 'Edit Category',
|
||||
icon: 'i-ph:pencil-duotone',
|
||||
color: 'green',
|
||||
click: category => openUpdateCategoryModal(category),
|
||||
},
|
||||
{
|
||||
label: 'Edit Tabs',
|
||||
icon: 'i-ph:cards-three-duotone',
|
||||
color: 'amber',
|
||||
click: category => currentEditCategory.value?.id === category.id ? currentEditCategory.value = null : currentEditCategory.value = category,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'i-ph:trash-duotone',
|
||||
@@ -64,6 +73,23 @@ defineShortcuts({
|
||||
createCategoryModal.value = true
|
||||
}
|
||||
},
|
||||
escape: () => {
|
||||
if (createCategoryModal.value) {
|
||||
createCategoryModal.value = false
|
||||
}
|
||||
if (updateCategoryModal.value) {
|
||||
updateCategoryModal.value = false
|
||||
}
|
||||
if (deleteCategoryModal.value) {
|
||||
deleteCategoryModal.value = false
|
||||
}
|
||||
if (createTabModal.value) {
|
||||
createTabModal.value = false
|
||||
}
|
||||
if (currentEditCategory.value) {
|
||||
currentEditCategory.value = null
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -116,11 +142,15 @@ defineShortcuts({
|
||||
:category="category"
|
||||
@create-tab="openCreateTab(category)"
|
||||
/>
|
||||
<div v-if="getTabsForCategory(category.id).length > 0" class="grid grid-cols-1 auto-rows-auto sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-if="getTabsForCategory(category.id).length > 0"
|
||||
class="grid grid-cols-1 auto-rows-auto sm:grid-cols-3 gap-4"
|
||||
>
|
||||
<AppTab
|
||||
v-for="tab in getTabsForCategory(category.id)"
|
||||
:key="tab.id"
|
||||
:tab="tab"
|
||||
:edit-mode="currentEditCategory?.id === category.id"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { z } from 'zod'
|
||||
import { useSession } from 'h3'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const { loggedIn } = useUserSession()
|
||||
const { loggedIn } = await useUserSession()
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'ghost',
|
||||
layout: 'login',
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
const state = reactive({ email: undefined })
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
// Do something with data
|
||||
// todo: add login logic
|
||||
console.log(event.data)
|
||||
state.email = ''
|
||||
}
|
||||
|
||||
const message = useState<string>('message')
|
||||
if (import.meta.server) {
|
||||
const session = await useSession(useRequestEvent()!, {
|
||||
@@ -62,20 +46,6 @@ if (import.meta.server) {
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="!loggedIn" class="flex flex-col gap-4 p-4">
|
||||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormGroup name="email">
|
||||
<UInput v-model="state.email" color="gray" placeholder="arthur@arthome.com" />
|
||||
</UFormGroup>
|
||||
<UButton
|
||||
:external="true"
|
||||
color="gray"
|
||||
icon="i-ph:envelope-duotone"
|
||||
label="Continue with Email"
|
||||
block
|
||||
type="submit"
|
||||
/>
|
||||
</UForm>
|
||||
<UDivider label="or" />
|
||||
<UButton
|
||||
:external="true"
|
||||
color="gray"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
import typography from '@tailwindcss/typography'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
content: [
|
||||
'./components/**/*.{vue,js,ts}',
|
||||
'./layouts/**/*.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./composables/**/*.{js,ts}',
|
||||
'./plugins/**/*.{js,ts}',
|
||||
'./utils/**/*.{js,ts}',
|
||||
'./App.{js,ts,vue}',
|
||||
'./app.{js,ts,vue}',
|
||||
'./Error.{js,ts,vue}',
|
||||
'./error.{js,ts,vue}',
|
||||
'./app.config.{js,ts}',
|
||||
'content/**/*.md',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||
},
|
||||
},
|
||||
plugins: [typography],
|
||||
}
|
||||
Reference in New Issue
Block a user