Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,25 @@ const logoTo = computed(() => user.value ? '/library' : '/login')
// Navigation links - only show Sign Out when logged in
const links = computed<NavigationMenuItem[]>(() => {
if (user.value) {
return [{
label: 'Sign Out',
icon: 'i-lucide-log-out',
color: 'neutral' as const,
variant: 'ghost' as const,
onClick: handleSignOut
}]
return [
{
label: 'Library',
icon: 'i-lucide-library',
to: '/library'
},
{
label: 'Loans',
icon: 'i-lucide-handshake',
to: '/library/loans'
},
{
label: 'Sign Out',
icon: 'i-lucide-log-out',
color: 'neutral' as const,
variant: 'ghost' as const,
onClick: handleSignOut
}
]
}
return []
})
Expand Down
19 changes: 19 additions & 0 deletions app/components/BookCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface Props {
isbn?: string | null
coverPath?: string | null
addedAt?: string | Date
activeLoan?: ActiveLoanSummary | null
selected?: boolean
selectable?: boolean
}
Expand Down Expand Up @@ -69,6 +70,15 @@ function handleClick(e: MouseEvent) {
class="text-4xl text-muted"
/>
</div>
<UBadge
v-if="activeLoan"
color="warning"
variant="solid"
size="sm"
class="absolute top-2 left-2"
>
Lent out
</UBadge>
</div>
</template>

Expand Down Expand Up @@ -111,6 +121,15 @@ function handleClick(e: MouseEvent) {
name="i-lucide-book"
class="text-4xl text-muted"
/>
<UBadge
v-if="activeLoan"
color="warning"
variant="solid"
size="sm"
class="absolute top-2 left-2"
>
Lent out
</UBadge>

<!-- Selection indicator -->
<div class="absolute top-2 right-2">
Expand Down
213 changes: 213 additions & 0 deletions app/components/BookLendingModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
<script setup lang="ts">
interface Props {
open: boolean
userBookId: string
saving?: boolean
}

const props = defineProps<Props>()

const emit = defineEmits<{
'update:open': [open: boolean]
'saved': [loan: OwnerLoan]
}>()

const toast = useToast()

const borrowerDisplayName = ref('')
const borrowerEmail = ref('')
const dueAt = ref('')
const isSaving = ref(false)
const inviteUrl = ref('')
const copiedAutomatically = ref(false)

const tomorrowDate = computed(() => {
const date = new Date()
date.setDate(date.getDate() + 1)
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0')
].join('-')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const modalOpen = computed({
get: () => props.open,
set: value => emit('update:open', value)
})

const isSaved = computed(() => Boolean(inviteUrl.value))
const canSave = computed(() => borrowerDisplayName.value.trim().length > 0 && !isSaving.value && !isSaved.value)

watch(
() => props.open,
(open) => {
if (!open) return
borrowerDisplayName.value = ''
borrowerEmail.value = ''
dueAt.value = ''
inviteUrl.value = ''
copiedAutomatically.value = false
}
)

async function lendBook() {
if (!canSave.value) return

isSaving.value = true
try {
const result = await $fetch<{ loan: OwnerLoan, inviteUrl: string }>(`/api/books/${props.userBookId}/loans`, {
method: 'POST',
body: {
borrowerDisplayName: borrowerDisplayName.value,
borrowerEmail: borrowerEmail.value || null,
dueAt: dueAt.value || null
}
})

inviteUrl.value = `${window.location.origin}${result.inviteUrl}`
copiedAutomatically.value = await copyInvite({ showToast: false })
emit('saved', { ...result.loan, inviteUrl: result.inviteUrl })
toast.add({
title: 'Book marked as lent out',
description: copiedAutomatically.value
? 'The share link was copied to your clipboard.'
: 'The share link is ready below.',
color: 'success'
})
} catch (err: unknown) {
const message = (err as { data?: { message?: string } })?.data?.message
?? (err instanceof Error ? err.message : 'Unable to lend this book')
toast.add({
title: 'Could not lend book',
description: message,
color: 'error'
})
} finally {
isSaving.value = false
}
}

async function copyInvite(options: { showToast?: boolean } = {}) {
if (!inviteUrl.value) return false

try {
await navigator.clipboard.writeText(inviteUrl.value)
if (options.showToast !== false) {
toast.add({
title: 'Invite link copied',
color: 'success'
})
}
return true
} catch {
if (options.showToast !== false) {
toast.add({
title: 'Could not copy link',
description: 'The share link is ready below.',
color: 'warning'
})
}
return false
}
}
</script>

<template>
<UModal
v-model:open="modalOpen"
title="Record a book loan"
description="Save who has this book."
:ui="{ content: 'sm:max-w-xl', footer: 'justify-end gap-2' }"
>
<template #body>
<div class="space-y-4">
<UAlert
v-if="!isSaved"
color="neutral"
variant="subtle"
icon="i-lucide-link"
title="A share link will be created"
description="Email is optional. After you save, Libroo copies a private link you can send however you like."
/>

<UFormField
label="Borrower name"
required
>
<UInput
v-model="borrowerDisplayName"
placeholder="Who has the book?"
icon="i-lucide-user"
class="w-full"
:disabled="isSaved"
/>
</UFormField>

<UFormField
label="Email (optional)"
help="Only for your private reference."
>
<UInput
v-model="borrowerEmail"
type="email"
placeholder="Optional"
icon="i-lucide-mail"
class="w-full"
:disabled="isSaved"
/>
</UFormField>

<UFormField label="Due date">
<UInput
v-model="dueAt"
type="date"
icon="i-lucide-calendar"
:min="tomorrowDate"
class="w-full"
:disabled="isSaved"
/>
</UFormField>

<UAlert
v-if="inviteUrl"
color="neutral"
variant="subtle"
icon="i-lucide-link"
title="Share link copied"
:description="copiedAutomatically ? 'Send the copied link so they can add this book to borrowed books.' : 'They can use this link to add the book to borrowed books.'"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-copy"
@click="copyInvite()"
>
Copy link
</UButton>
</template>
</UAlert>
</div>
</template>

<template #footer>
<UButton
color="neutral"
variant="soft"
@click="modalOpen = false"
>
Close
</UButton>
<UButton
v-if="!isSaved"
icon="i-lucide-handshake"
:loading="isSaving"
:disabled="!canSave"
@click="lendBook"
>
Save loan
</UButton>
</template>
</UModal>
</template>
Loading