refactor(forgeCLI): massive refactoring

This commit is contained in:
Melvin Chia
2026-01-04 17:02:47 +08:00
parent 271a18c605
commit 3b6f59cd8d
122 changed files with 3191 additions and 2381 deletions

View File

@@ -0,0 +1,14 @@
import type { MovieEntry } from '..'
import MovieItem from './MovieItem'
function MovieGrid({ data }: { data: MovieEntry[] }) {
return (
<ul className="mb-32 grid grid-cols-[repeat(auto-fill,minmax(24rem,1fr))] gap-3 md:mb-12">
{data.map(item => (
<MovieItem key={item.id} data={item} type="grid" />
))}
</ul>
)
}
export default MovieGrid

View File

@@ -0,0 +1,285 @@
import forgeAPI from '@/utils/forgeAPI'
import { Icon } from '@iconify/react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import clsx from 'clsx'
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import {
Button,
ConfirmationModal,
ContextMenu,
ContextMenuItem,
Card
} from 'lifeforge-ui'
import { useModalStore } from 'lifeforge-ui'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import type { InferOutput } from 'shared'
import { usePersonalization, usePromiseLoading } from 'shared'
import ModifyTicketModal from '../modals/ModifyTicketModal'
import ShowTicketModal from '../modals/ShowTicketModal'
dayjs.extend(duration)
dayjs.extend(relativeTime)
function MovieItem({
data,
type
}: {
data: InferOutput<typeof forgeAPI.movies.entries.list>['entries'][number]
type: 'grid' | 'list'
}) {
const queryClient = useQueryClient()
const { t } = useTranslation('apps.movies')
const { language } = usePersonalization()
const open = useModalStore(state => state.open)
const toggleWatchedMutation = useMutation(
forgeAPI.movies.entries.toggleWatchStatus
.input({
id: data.id
})
.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['movies', 'entries']
})
},
onError: () => {
toast.error('Failed to mark movie as watched.')
}
})
)
const [toggleWatchedLoading, handleToggleWatched] = usePromiseLoading(() =>
toggleWatchedMutation.mutateAsync({})
)
const updateMovieDataMutation = useMutation(
forgeAPI.movies.entries.update.input({ id: data.id }).mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['movies', 'entries']
})
toast.success('Movie data updated successfully.')
},
onError: () => {
toast.error('Failed to update movie data.')
}
})
)
const [updateMovieDataLoading, handleUpdateMovieData] = usePromiseLoading(
() => updateMovieDataMutation.mutateAsync({})
)
const handleShowTicket = useCallback(() => {
open(ShowTicketModal, {
entry: data
})
}, [data, open])
const handleUpdateTicket = useCallback(() => {
open(ModifyTicketModal, {
initialData: data,
type: data.ticket_number ? 'update' : 'create'
})
}, [data])
const deleteMutation = useMutation(
forgeAPI.movies.entries.remove.input({ id: data.id }).mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['movies'] })
},
onError: () => {
toast.error('Failed to delete movie entry')
}
})
)
const handleDeleteTicket = useCallback(() => {
open(ConfirmationModal, {
title: 'Delete Movie',
description: 'Are you sure you want to delete this movie?',
confirmationButton: 'delete',
onConfirm: async () => {
await deleteMutation.mutateAsync({})
}
})
}, [data])
return (
<Card
as="li"
className={clsx(
'flex items-center gap-6',
type === 'grid' ? 'flex-col' : 'flex-col md:flex-row'
)}
>
<div className="bg-bg-200 dark:bg-bg-800 relative isolate flex h-66 w-48 shrink-0 items-center justify-center overflow-hidden rounded-md">
<Icon
className="text-bg-300 dark:text-bg-700 absolute top-1/2 left-1/2 z-[-1] size-18 -translate-x-1/2 -translate-y-1/2 transform"
icon="tabler:movie"
/>
<img
alt=""
className="h-full w-full rounded-md object-cover"
src={`http://image.tmdb.org/t/p/w300/${data.poster}`}
/>
</div>
<div className="flex w-full flex-1 flex-col">
<p className="text-custom-500 mb-1 font-semibold">
{dayjs(data.release_date).year()}
</p>
<h1 className="text-xl font-semibold">
{data.title}
<span className="text-bg-500 ml-1 text-base font-medium">
({data.original_title})
</span>
</h1>
<p className="text-bg-500 mt-2 line-clamp-2">{data.overview}</p>
<div className="mt-4 flex-1">
<div className="flex flex-wrap items-start gap-x-8 gap-y-4">
<div className="space-y-2">
<div className="text-bg-500 flex items-center gap-2 font-medium">
<Icon className="size-5" icon="tabler:category" />
Genres
</div>
<div>{data.genres.join(', ')}</div>
</div>
<div className="space-y-2">
<div className="text-bg-500 flex items-center gap-2 font-medium">
<Icon className="size-5" icon="tabler:calendar" />
Release Date
</div>
<div>
{data.release_date
? dayjs(data.release_date).format('DD MMM YYYY')
: 'TBA'}
</div>
</div>
<div className="space-y-2">
<div className="text-bg-500 flex items-center gap-2 font-medium">
<Icon className="size-5" icon="tabler:clock" />
Duration
</div>
<div>
{dayjs
.duration(data.duration, 'minutes')
.format('H [h] mm [m]')}
</div>
</div>
<div className="space-y-2">
<div className="text-bg-500 flex items-center gap-2 font-medium">
<Icon className="size-5" icon="uil:globe" />
Language
</div>
<div>{data.language}</div>
</div>
<div className="space-y-2">
<div className="text-bg-500 flex items-center gap-2 font-medium">
<Icon className="size-5" icon="tabler:flag" />
Countries
</div>
<div className="flex items-center gap-3">
{data.countries.map((country: string, index: number) => (
<div
key={`country-${index}-${country}`}
className="flex items-center gap-2"
>
<Icon
className="size-5"
icon={`circle-flags:${country.toLowerCase()}`}
/>
{country}
</div>
))}
</div>
</div>
</div>
</div>
<div
className={clsx(
'mt-6 flex gap-2',
type === 'grid' ? 'flex-col' : 'flex-col md:flex-row'
)}
>
{!data.is_watched && (
<Button
className="w-full"
icon="tabler:check"
loading={toggleWatchedLoading}
namespace="apps.movies"
variant="secondary"
onClick={handleToggleWatched}
>
Mark as Watched
</Button>
)}
{data.ticket_number && (
<Button
className="w-full"
icon="tabler:ticket"
namespace="apps.movies"
variant={data.is_watched ? 'secondary' : 'primary'}
onClick={handleShowTicket}
>
Show Ticket
</Button>
)}
{data.is_watched && (
<div className="flex-center text-bg-500 mt-4 mb-2 w-full gap-2">
<Icon className="size-5" icon="tabler:check" />
{t('misc.watched', {
date: dayjs(data.watch_date).locale(language).fromNow()
})}
</div>
)}
</div>
</div>
<ContextMenu
classNames={{
wrapper: 'absolute right-4 top-4'
}}
>
{data.is_watched && (
<ContextMenuItem
icon="tabler:eye-off"
label="Mark as Unwatched"
namespace="apps.movies"
onClick={handleToggleWatched}
/>
)}
<ContextMenuItem
icon="tabler:ticket"
label={data.ticket_number ? 'Update Ticket' : 'Add Ticket'}
namespace="apps.movies"
onClick={handleUpdateTicket}
/>
<ContextMenuItem
icon="tabler:refresh"
label="Update Movie Data"
loading={updateMovieDataLoading}
namespace="apps.movies"
shouldCloseMenuOnClick={false}
onClick={handleUpdateMovieData}
/>
<ContextMenuItem
dangerous
icon="tabler:trash"
label="Delete"
onClick={handleDeleteTicket}
/>
</ContextMenu>
</Card>
)
}
export default MovieItem

View File

@@ -0,0 +1,14 @@
import type { MovieEntry } from '..'
import MovieItem from './MovieItem'
function MovieList({ data }: { data: MovieEntry[] }) {
return (
<ul className="mb-24 space-y-3 md:mb-12">
{data.map(item => (
<MovieItem key={item.id} data={item} type="list" />
))}
</ul>
)
}
export default MovieList

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 190.24 81.52"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="40.76" x2="190.24" y2="40.76" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 2</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,177 @@
import forgeAPI from '@/utils/forgeAPI'
import { useQuery } from '@tanstack/react-query'
import {
Button,
EmptyStateScreen,
FAB,
ModuleHeader,
Scrollbar,
SearchInput,
Tabs,
ViewModeSelector,
WithQuery
} from 'lifeforge-ui'
import { useModalStore } from 'lifeforge-ui'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'shared'
import type { InferOutput } from 'shared'
import MovieGrid from './components/MovieGrid'
import MovieList from './components/MovieList'
import SearchTMDBModal from './modals/SearchTMDBModal'
import ShowTicketModal from './modals/ShowTicketModal'
export type MovieEntry = InferOutput<
typeof forgeAPI.movies.entries.list
>['entries'][number]
function Movies() {
const open = useModalStore(state => state.open)
const { t } = useTranslation('apps.movies')
const [searchParams, setSearchParams] = useSearchParams()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [searchQuery, setSearchQuery] = useState('')
const [currentTab, setCurrentTab] = useState<'unwatched' | 'watched'>(
'unwatched'
)
const entriesQuery = useQuery(
forgeAPI.movies.entries.list
.input({
watched: currentTab === 'watched' ? 'true' : 'false'
})
.queryOptions()
)
useEffect(() => {
if (!entriesQuery.data) return
if (searchParams.get('show-ticket')) {
const target = entriesQuery.data.entries.find(
entry => entry.id === searchParams.get('show-ticket')
)
if (!target) return
open(ShowTicketModal, {
entry: target
})
setSearchParams({}, { replace: true })
}
}, [searchParams, setSearchParams, entriesQuery.data])
const handleOpenTMDBModal = useCallback(() => {
open(SearchTMDBModal, {})
}, [entriesQuery.data])
return (
<>
<ModuleHeader
actionButton={
<Button
className="hidden md:flex"
icon="tabler:plus"
tProps={{ item: t('items.movie') }}
onClick={handleOpenTMDBModal}
>
new
</Button>
}
/>
<div className="flex items-center gap-2">
<SearchInput
debounceMs={300}
namespace="apps.movies"
searchTarget="movie"
value={searchQuery}
onChange={setSearchQuery}
/>
<ViewModeSelector
className="hidden md:flex"
currentMode={viewMode}
options={[
{ icon: 'uil:apps', value: 'grid' },
{ icon: 'tabler:list', value: 'list' }
]}
onModeChange={setViewMode}
/>
</div>
<WithQuery query={entriesQuery}>
{data => {
const FinalComponent = viewMode === 'grid' ? MovieGrid : MovieList
return (
<div className="flex flex-1 flex-col space-y-3">
<Tabs
currentTab={currentTab}
enabled={['unwatched', 'watched']}
items={[
{
id: 'unwatched',
name: t('tabs.unwatched'),
icon: 'tabler:eye-off',
amount:
currentTab === 'unwatched'
? data.entries.length
: data.total - data.entries.length
},
{
id: 'watched',
name: t('tabs.watched'),
icon: 'tabler:eye',
amount:
currentTab === 'watched'
? data.entries.length
: data.total - data.entries.length
}
]}
onTabChange={setCurrentTab}
/>
{data.entries.length === 0 ? (
<EmptyStateScreen
CTAButtonProps={{
onClick: handleOpenTMDBModal,
tProps: { item: t('items.movie') },
icon: 'tabler:plus',
children: 'new'
}}
icon="tabler:movie-off"
message={{
id: 'library',
namespace: 'apps.movies'
}}
/>
) : (
<Scrollbar>
<FinalComponent
data={data.entries.filter(entry => {
const matchesSearch = entry.title
.toLowerCase()
.includes(searchQuery.toLowerCase())
const matchesTab =
currentTab === 'unwatched'
? !entry.is_watched
: entry.is_watched
return matchesSearch && matchesTab
})}
/>
</Scrollbar>
)}
</div>
)
}}
</WithQuery>
<FAB visibilityBreakpoint="md" onClick={handleOpenTMDBModal} />
</>
)
}
export default Movies

View File

@@ -0,0 +1,138 @@
import forgeAPI from '@/utils/forgeAPI'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ConfirmationModal, FormModal, defineForm } from 'lifeforge-ui'
import { useModalStore } from 'lifeforge-ui'
import { toast } from 'react-toastify'
import type { InferInput } from 'shared'
import type { MovieEntry } from '..'
function ModifyTicketModal({
data: { type, initialData },
onClose
}: {
data: {
type: 'create' | 'update'
initialData: MovieEntry
}
onClose: () => void
}) {
const queryClient = useQueryClient()
const open = useModalStore(state => state.open)
const modifyTicketMutation = useMutation(
forgeAPI.movies.ticket.update
.input({
id: initialData.id
})
.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['movies', 'entries']
})
}
})
)
const deleteMutation = useMutation(
forgeAPI.movies.ticket.clear
.input({
id: initialData.id
})
.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['movies', 'entries']
})
toast.success('Ticket deleted successfully!')
onClose()
}
})
)
const handleDeleteTicket = () =>
open(ConfirmationModal, {
title: 'Delete Ticket',
description: 'Are you sure you want to delete this ticket?',
confirmationButton: 'delete',
onConfirm: async () => {
await deleteMutation.mutateAsync({})
}
})
const { formProps } = defineForm<
InferInput<typeof forgeAPI.movies.ticket.update>['body']
>({
icon: type === 'create' ? 'tabler:plus' : 'tabler:pencil',
title: `ticket.${type}`,
namespace: 'apps.movies',
onClose,
submitButton: type,
actionButton: {
dangerous: true,
icon: 'tabler:trash',
onClick: handleDeleteTicket
}
})
.typesMap({
ticket_number: 'text',
theatre_seat: 'text',
theatre_location: 'location',
theatre_showtime: 'datetime',
theatre_number: 'text'
})
.setupFields({
ticket_number: {
required: true,
label: 'Ticket number',
icon: 'tabler:ticket',
placeholder: '123456789',
qrScanner: true
},
theatre_seat: {
label: 'Theatre seat',
icon: 'mdi:love-seat-outline',
placeholder: 'A1'
},
theatre_location: {
label: 'Theatre location',
type: 'location'
},
theatre_showtime: {
label: 'Theatre showtime',
icon: 'tabler:clock',
type: 'datetime',
hasTime: true
},
theatre_number: {
label: 'Theatre number',
icon: 'tabler:hash',
placeholder: '1'
}
})
.initialData({
ticket_number: initialData.ticket_number || '',
theatre_location: {
name: initialData.theatre_location || '',
location: {
latitude: initialData.theatre_location_coords?.lat || 0,
longitude: initialData.theatre_location_coords?.lon || 0
},
formattedAddress: initialData.theatre_location || ''
},
theatre_number: initialData.theatre_number || '',
theatre_seat: initialData.theatre_seat || '',
theatre_showtime: initialData.theatre_showtime
? new Date(initialData.theatre_showtime)
: undefined
})
.onSubmit(async data => {
await modifyTicketMutation.mutateAsync(data)
})
.build()
return <FormModal {...formProps} />
}
export default ModifyTicketModal

View File

@@ -0,0 +1,36 @@
function TMDBLogo({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 190.24 81.52"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<style>{'.cls-1{fill:url(#linear-gradient);}'}</style>
<linearGradient
gradientUnits="userSpaceOnUse"
id="linear-gradient"
x2="190.24"
y1="40.76"
y2="40.76"
>
<stop offset="0" stopColor="#90cea1" />
<stop offset="0.56" stopColor="#3cbec9" />
<stop offset="1" stopColor="#00b3e5" />
</linearGradient>
</defs>
<title>Asset 2</title>
<g data-label="Layer 2" id="Layer_2">
<g data-label="Layer 1" id="Layer_1-2">
<path
className="cls-1"
d="M105.67,36.06h66.9A17.67,17.67,0,0,0,190.24,18.4h0A17.67,17.67,0,0,0,172.57.73h-66.9A17.67,17.67,0,0,0,88,18.4h0A17.67,17.67,0,0,0,105.67,36.06Zm-88,45h76.9A17.67,17.67,0,0,0,112.24,63.4h0A17.67,17.67,0,0,0,94.57,45.73H17.67A17.67,17.67,0,0,0,0,63.4H0A17.67,17.67,0,0,0,17.67,81.06ZM10.41,35.42h7.8V6.92h10.1V0H.31v6.9h10.1Zm28.1,0h7.8V8.25h.1l9,27.15h6l9.3-27.15h.1V35.4h7.8V0H66.76l-8.2,23.1h-.1L50.31,0H38.51ZM152.43,55.67a15.07,15.07,0,0,0-4.52-5.52,18.57,18.57,0,0,0-6.68-3.08,33.54,33.54,0,0,0-8.07-1h-11.7v35.4h12.75a24.58,24.58,0,0,0,7.55-1.15A19.34,19.34,0,0,0,148.11,77a16.27,16.27,0,0,0,4.37-5.5,16.91,16.91,0,0,0,1.63-7.58A18.5,18.5,0,0,0,152.43,55.67ZM145,68.6A8.8,8.8,0,0,1,142.36,72a10.7,10.7,0,0,1-4,1.82,21.57,21.57,0,0,1-5,.55h-4.05v-21h4.6a17,17,0,0,1,4.67.63,11.66,11.66,0,0,1,3.88,1.87A9.14,9.14,0,0,1,145,59a9.87,9.87,0,0,1,1,4.52A11.89,11.89,0,0,1,145,68.6Zm44.63-.13a8,8,0,0,0-1.58-2.62A8.38,8.38,0,0,0,185.63,64a10.31,10.31,0,0,0-3.17-1v-.1a9.22,9.22,0,0,0,4.42-2.82,7.43,7.43,0,0,0,1.68-5,8.42,8.42,0,0,0-1.15-4.65,8.09,8.09,0,0,0-3-2.72,12.56,12.56,0,0,0-4.18-1.3,32.84,32.84,0,0,0-4.62-.33h-13.2v35.4h14.5a22.41,22.41,0,0,0,4.72-.5,13.53,13.53,0,0,0,4.28-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68A9.39,9.39,0,0,0,189.66,68.47ZM170.21,52.72h5.3a10,10,0,0,1,1.85.18,6.18,6.18,0,0,1,1.7.57,3.39,3.39,0,0,1,1.22,1.13,3.22,3.22,0,0,1,.48,1.82,3.63,3.63,0,0,1-.43,1.8,3.4,3.4,0,0,1-1.12,1.2,4.92,4.92,0,0,1-1.58.65,7.51,7.51,0,0,1-1.77.2h-5.65Zm11.72,20a3.9,3.9,0,0,1-1.22,1.3,4.64,4.64,0,0,1-1.68.7,8.18,8.18,0,0,1-1.82.2h-7v-8h5.9a15.35,15.35,0,0,1,2,.15,8.47,8.47,0,0,1,2.05.55,4,4,0,0,1,1.57,1.18,3.11,3.11,0,0,1,.63,2A3.71,3.71,0,0,1,181.93,72.72Z"
/>
</g>
</g>
</svg>
)
}
export default TMDBLogo

View File

@@ -0,0 +1,79 @@
import forgeAPI from '@/utils/forgeAPI'
import { Icon } from '@iconify/react'
import { useMutation } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { Button } from 'lifeforge-ui'
import { toast } from 'react-toastify'
import { usePromiseLoading } from 'shared'
import type { TMDBSearchResults } from '..'
function TMDBResultItem({
data,
isAdded,
onAddToLibrary
}: {
data: TMDBSearchResults['results'][number]
isAdded: boolean
onAddToLibrary: () => Promise<void>
}) {
const addToLibraryMutation = useMutation(
forgeAPI.movies.entries.create
.input({ id: data.id.toString() })
.mutationOptions({
onSuccess: async () => {
await onAddToLibrary()
},
onError: (error: any) => {
toast.error(
`Failed to add movie: ${error.message || 'Unknown error'}`
)
}
})
)
const [loading, onSubmit] = usePromiseLoading(() =>
addToLibraryMutation.mutateAsync({})
)
return (
<div className="component-bg-lighter shadow-custom flex flex-col items-center gap-6 rounded-md p-4 md:flex-row">
<div className="bg-bg-200 dark:bg-bg-800 relative isolate h-48 w-32 shrink-0">
<Icon
className="text-bg-300 dark:text-bg-700 absolute top-1/2 left-1/2 z-[-1] size-18 -translate-x-1/2 -translate-y-1/2 transform"
icon="tabler:movie"
/>
<img
alt=""
className="rounded-md object-contain"
src={`http://image.tmdb.org/t/p/w154/${data.poster_path}`}
/>
</div>
<div className="w-full">
<p className="text-custom-500 font-semibold">
{dayjs(data.release_date).year()}
</p>
<h1 className="text-xl font-semibold">
{data.title}{' '}
<span className="text-bg-500 text-base font-medium">
({data.original_title})
</span>
</h1>
<p className="text-bg-500 mt-2 line-clamp-2">{data.overview}</p>
<Button
className="mt-4 w-full"
disabled={isAdded}
icon="tabler:plus"
loading={loading}
namespace="apps.movies"
variant={isAdded ? 'plain' : 'primary'}
onClick={onSubmit}
>
{isAdded ? 'Already in Library' : 'Add to Library'}
</Button>
</div>
</div>
)
}
export default TMDBResultItem

View File

@@ -0,0 +1,63 @@
import { EmptyStateScreen, Pagination } from 'lifeforge-ui'
import type { TMDBSearchResults } from '..'
import TMDBResultItem from './TMDBResultItem'
function TMDBResultsList({
results,
page,
setPage,
onAddToLibrary
}: {
results: TMDBSearchResults
page: number
setPage: React.Dispatch<React.SetStateAction<number>>
onAddToLibrary: () => Promise<void>
}) {
if (results === null) {
return <></>
}
if (results.total_results === 0) {
return (
<div className="mt-6 h-96">
<EmptyStateScreen
icon="tabler:search-off"
message={{
id: 'search',
namespace: 'apps.movies'
}}
/>
</div>
)
}
return (
<>
<Pagination
className="mt-6 mb-4"
page={page}
totalPages={results.total_pages}
onPageChange={setPage}
/>
<div className="mt-6 w-full space-y-2">
{results.results.map(entry => (
<TMDBResultItem
key={entry.id}
data={entry}
isAdded={entry.existed}
onAddToLibrary={onAddToLibrary}
/>
))}
</div>
<Pagination
className="mt-4"
page={page}
totalPages={results.total_pages}
onPageChange={setPage}
/>
</>
)
}
export default TMDBResultsList

View File

@@ -0,0 +1,133 @@
import forgeAPI from '@/utils/forgeAPI'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
EmptyStateScreen,
ModalHeader,
SearchInput,
WithQuery
} from 'lifeforge-ui'
import { useState } from 'react'
import { toast } from 'react-toastify'
import type { InferOutput } from 'shared'
import TMDBLogo from './components/TMDBLogo'
import TMDBResultsList from './components/TMDBResultsList'
export type TMDBSearchResults = InferOutput<typeof forgeAPI.movies.tmdb.search>
function SearchTMDBModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient()
const [searchQuery, setSearchQuery] = useState('')
const [queryToSearch, setQueryToSearch] = useState('')
const [page, setPage] = useState(1)
const searchResultsQuery = useQuery(
forgeAPI.movies.tmdb.search
.input({
q: queryToSearch,
page: page.toString()
})
.queryOptions({
enabled: !!queryToSearch
})
)
const onAddToLibrary = async () => {
await queryClient.invalidateQueries({
queryKey: ['movies', 'entries']
})
await queryClient.invalidateQueries({
queryKey: forgeAPI.movies.tmdb.search.input({
q: queryToSearch,
page: page.toString()
}).key
})
toast.success('Movie added to your library!')
}
return (
<div className="min-w-[70vw]">
<ModalHeader
appendTitle={
<p className="text-bg-500 shrink-0 text-right text-sm sm:text-base">
powered by&nbsp;
<a
className="underline"
href="https://iconify.design"
rel="noreferrer"
target="_blank"
>
<TMDBLogo className="ml-2 inline h-4" />
</a>
</p>
}
icon="tabler:movie"
namespace="apps.movies"
title="Search TMDB"
onClose={onClose}
/>
<div className="flex flex-col items-center gap-2 sm:flex-row">
<SearchInput
className="component-bg-lighter-with-hover"
namespace="apps.movies"
searchTarget="movie"
value={searchQuery}
onChange={setSearchQuery}
onKeyUp={e => {
if (e.key === 'Enter') {
if (searchQuery.trim() !== '') {
setPage(1)
setQueryToSearch(searchQuery.trim())
}
}
}}
/>
<Button
className="w-full sm:w-auto"
disabled={searchQuery.trim() === ''}
icon="tabler:arrow-right"
iconPosition="end"
loading={searchResultsQuery.isLoading}
onClick={() => {
setPage(1)
setQueryToSearch(searchQuery.trim())
}}
>
search
</Button>
</div>
<div className="mt-6">
{queryToSearch ? (
<WithQuery query={searchResultsQuery}>
{searchResults => (
<TMDBResultsList
page={page}
results={searchResults}
setPage={setPage}
onAddToLibrary={onAddToLibrary}
/>
)}
</WithQuery>
) : (
<div className="h-96">
<EmptyStateScreen
icon={<TMDBLogo className="h-24" />}
message={{
id: 'tmdb',
namespace: 'apps.movies'
}}
/>
</div>
)}
</div>
</div>
)
}
export default SearchTMDBModal

View File

@@ -0,0 +1,63 @@
import { Icon } from '@iconify/react'
import dayjs from 'dayjs'
import { ModalHeader } from 'lifeforge-ui'
import { QRCodeSVG } from 'qrcode.react'
import type { MovieEntry } from '..'
function ShowTicketModal({
data: { entry },
onClose
}: {
data: {
entry: MovieEntry | null
}
onClose: () => void
}) {
return (
<div className="lg:max-w-[20rem]">
<ModalHeader
icon="tabler:ticket"
namespace="apps.movies"
title="ticket.view"
onClose={onClose}
/>
{entry && (
<>
<div className="flex-center w-full">
<div className="flex aspect-square h-auto w-full max-w-[20rem] items-center justify-center rounded-lg bg-white p-8">
<QRCodeSVG
className="h-full w-full"
value={entry.ticket_number}
/>
,
</div>
</div>
<h2 className="mt-6 text-xl font-medium">{entry.title}</h2>
<div className="text-bg-500 mt-6 space-y-3">
<div className="flex items-center gap-2">
<Icon className="size-5 shrink-0" icon="tabler:map-pin" />
{entry.theatre_location || 'N/A'}
</div>
<div className="flex items-center gap-2">
<Icon className="size-5 shrink-0" icon="tabler:calendar" />
{entry.theatre_showtime
? dayjs(entry.theatre_showtime).format('DD MMM YYYY, h:mm a')
: 'N/A'}
</div>
<div className="flex items-center gap-2">
<Icon className="size-5 shrink-0" icon="tabler:hash" />
Theatre No. {entry.theatre_number || 'N/A'}
</div>
<div className="flex items-center gap-2">
<Icon className="size-5 shrink-0" icon="mdi:love-seat-outline" />
{entry.theatre_seat || 'N/A'}
</div>
</div>
</>
)}
</div>
)
}
export default ShowTicketModal

View File

@@ -0,0 +1,49 @@
{
"compilerOptions": {
// JSX and Language Settings
"jsx": "react-jsx",
"target": "ES2020",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable",
"ES2024.Object"
],
// Module Resolution
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
// Build and Output
"composite": true,
"noEmit": true,
"skipLibCheck": true,
// Type Checking
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"useDefineForClassFields": true,
"noFallthroughCasesInSwitch": true,
// Path Mapping
"paths": {
"@": [
"./src/index"
],
"@/*": [
"./*"
],
"@server/*": [
"../../../server/src/*"
]
}
},
"include": [
"./**/*"
],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
}

View File

@@ -0,0 +1,10 @@
import { type AppRoutes } from '@server/core/routes/routes.type'
import { createForgeAPIClient } from 'shared'
if (!import.meta.env.VITE_API_HOST) {
throw new Error('VITE_API_HOST is not defined')
}
const forgeAPI = createForgeAPIClient<AppRoutes>(import.meta.env.VITE_API_HOST)
export default forgeAPI

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,59 @@
{
"title": "Movies",
"description": "Your personal log for every movie youve watched.",
"items": {
"movie": "Movie"
},
"modals": {
"searchTmdb": {
"title": "Search Movie"
},
"ticket": {
"create": "Add Ticket",
"update": "Update Ticket",
"view": "View Ticket"
}
},
"empty": {
"tmdb": {
"title": "Powered by The Movie DB",
"description": "Millions of movies to discover."
},
"search": {
"title": "Movies Not Found",
"description": "Please try entering a different search keyword."
},
"library": {
"title": "No Movies in Library",
"description": "Add movies to your library by clicking the button below."
}
},
"buttons": {
"addToLibrary": "Add to Library",
"alreadyInLibrary": "Already in Library",
"addTicket": "Add Ticket",
"updateTicket": "Update Ticket",
"markAsWatched": "Mark as Watched",
"markAsUnwatched": "Mark as Unwatched",
"watched": "Watched",
"showTicket": "Show Ticket",
"addToCalendar": "Add to Calendar",
"addedToCalendar": "Added to Calendar",
"updateMovieData": "Update Movie Data"
},
"inputs": {
"ticketNumber": "Ticket Number",
"theatreSeat": "Theatre Seat",
"theatreLocation": "Theatre Location",
"theatreShowtime": "Theatre Showtime",
"theatreNumber": "Theatre Number",
"category": "Category"
},
"tabs": {
"watched": "Watched",
"unwatched": "Unwatched"
},
"misc": {
"watched": "Watched on {{date}}"
}
}

View File

@@ -0,0 +1,59 @@
{
"title": "Filem",
"description": "Setiap filem, sebuah cerita untuk diingati.",
"items": {
"movie": "Filem"
},
"modals": {
"searchTmdb": {
"title": "Cari Filem"
},
"ticket": {
"create": "Tambah Tiket",
"update": "Kemaskini Tiket",
"view": "Lihat Tiket"
}
},
"empty": {
"tmdb": {
"title": "Dikuasakan oleh Pangkalan Data Filem",
"description": "Berjuta-juta filem untuk ditemui."
},
"search": {
"title": "Filem Tidak Ditemui",
"description": "Sila cuba masukkan kata kunci carian yang berbeza."
},
"library": {
"title": "Tiada Filem dalam Pustaka",
"description": "Tambah filem ke dalam pustaka anda dengan menekan butang di bawah."
}
},
"buttons": {
"addToLibrary": "Tambah ke Pustaka",
"alreadyInLibrary": "Sudah di dalam Pustaka",
"addTicket": "Tambah Tiket",
"updateTicket": "Kemaskini Tiket",
"markAsWatched": "Tandakan sebagai Ditonton",
"markAsUnwatched": "Tandakan sebagai Tidak Ditonton",
"watched": "Telah Ditonton",
"showTicket": "Tunjukkan Tiket",
"addToCalendar": "Tambah ke Kalendar",
"addedToCalendar": "Ditambahkan ke Kalendar",
"updateMovieData": "Kemas Kini Data Filem"
},
"inputs": {
"ticketNumber": "Nombor Tiket",
"theatreSeat": "Tempat Duduk Pawagam",
"theatreLocation": "Lokasi Pawagam",
"theatreShowtime": "Masa Tayangan Pawagam",
"theatreNumber": "Nombor Pawagam",
"category": "Kategori"
},
"tabs": {
"watched": "Ditonton",
"unwatched": "Belum ditonton"
},
"misc": {
"watched": "Ditonton pada {{date}}"
}
}

View File

@@ -0,0 +1,59 @@
{
"title": "電影",
"description": "每一部电影,是一个值得铭记的故事。",
"items": {
"movie": "电影"
},
"modals": {
"searchTmdb": {
"title": "搜索电影"
},
"ticket": {
"create": "新增票券",
"update": "更新票券",
"view": "查看票券"
}
},
"empty": {
"tmdb": {
"title": "由 The Movie DB 提供支援",
"description": "数百万部电影等待您发现。"
},
"search": {
"title": "找不到电影",
"description": "请尝试输入不同的搜索关键字。"
},
"library": {
"title": "没有电影在库",
"description": "点击下面的按钮将电影添加到您的库中。"
}
},
"buttons": {
"addToLibrary": "加入影片库",
"alreadyInLibrary": "已在影片库",
"addTicket": "新增票券",
"updateTicket": "更新票券",
"markAsWatched": "标记为已观看",
"markAsUnwatched": "标记为未观看",
"watched": "已观看",
"showTicket": "显示票据",
"addToCalendar": "添加到日历",
"addedToCalendar": "已添加到日历",
"updateMovieData": "更新电影数据"
},
"inputs": {
"ticketNumber": "票券号码",
"theatreSeat": "影院座位",
"theatreLocation": "影院地点",
"theatreShowtime": "影院放映时间",
"theatreNumber": "影院号码",
"category": "类别"
},
"tabs": {
"watched": "已观看",
"unwatched": "未观看"
},
"misc": {
"watched": "观看于{{date}}"
}
}

View File

@@ -0,0 +1,59 @@
{
"title": "電影",
"description": "每一部電影,是一個值得銘記的故事。",
"items": {
"movie": "電影"
},
"modals": {
"searchTmdb": {
"title": "搜尋電影"
},
"ticket": {
"create": "新增票券",
"update": "更新票券",
"view": "查看票券"
}
},
"empty": {
"tmdb": {
"title": "由 The Movie DB 提供支援",
"description": "數百萬部電影等待您發現。"
},
"search": {
"title": "找不到電影",
"description": "請嘗試輸入不同的搜尋關鍵字。"
},
"library": {
"title": "沒有電影在庫",
"description": "點擊下面的按鈕將電影添加到您的庫中。"
}
},
"buttons": {
"addToLibrary": "加入影片庫",
"alreadyInLibrary": "已在影片庫",
"addTicket": "新增票券",
"updateTicket": "更新票券",
"markAsWatched": "標記為已觀看",
"markAsUnwatched": "標記為未觀看",
"watched": "已觀看",
"showTicket": "顯示票據",
"addToCalendar": "添加到日曆",
"addedToCalendar": "已加入日曆",
"updateMovieData": "更新電影資料"
},
"inputs": {
"ticketNumber": "票券號碼",
"theatreSeat": "影院座位",
"theatreLocation": "影院地點",
"theatreShowtime": "影院放映時間",
"theatreNumber": "影院號碼",
"category": "類別"
},
"tabs": {
"watched": "已觀看",
"unwatched": "未觀看"
},
"misc": {
"watched": "觀看於{{date}}"
}
}

7
apps/lifeforge--movies/manifest.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
// AUTO-GENERATED - DO NOT EDIT
// This declaration file allows TypeScript to type-check module imports
// without resolving internal module aliases like @
import type { ModuleConfig } from 'shared'
declare const manifest: ModuleConfig
export default manifest

View File

@@ -0,0 +1,9 @@
import { lazy } from 'react'
import type { ModuleConfig } from 'shared'
export default {
routes: {
'/': lazy(() => import('@'))
},
} satisfies ModuleConfig

View File

@@ -0,0 +1,47 @@
{
"name": "@lifeforge/lifeforge--movies",
"displayName": "Movies Library",
"version": "0.1.0",
"description": "Your personal log for every movie youve watched.",
"scripts": {
"types": "cd client && bun tsc"
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@tanstack/react-query": "^5.90.11",
"@uidotdev/usehooks": "^2.4.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"lifeforge-ui": "workspace:*",
"moment": "^2.30.1",
"qrcode.react": "^4.2.0",
"react-i18next": "^15.1.1",
"react-toastify": "^11.0.5",
"shared": "workspace:*",
"vite": "^7.1.9",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.1"
},
"author": "Melvin Chia <melvinchia623600@gmail.com>",
"exports": {
"./server": "./server/index.ts",
"./manifest": {
"types": "./manifest.d.ts",
"default": "./manifest.ts"
},
"./server/schema": "./server/schema.ts"
},
"lifeforge": {
"icon": "tabler:movie",
"APIKeyAccess": {
"tmdb": {
"required": true,
"usage": "Fetch movie data from TMDB"
}
},
"category": "Lifestyle"
}
}

View File

@@ -0,0 +1,52 @@
import { PBService } from '@functions/database'
import moment from 'moment'
export default async function getEvents({
pb,
start,
end
}: {
pb: PBService
start: string
end: string
}) {
return (
await pb.getFullList
.collection('movies__entries')
.filter([
{
field: 'theatre_showtime',
operator: '>=',
value: start
},
{ field: 'theatre_showtime', operator: '<=', value: end }
])
.execute()
.catch(() => [])
).map(entry => ({
id: entry.id,
type: 'single' as const,
title: entry.title,
start: entry.theatre_showtime,
end: moment(entry.theatre_showtime)
.add(entry.duration, 'minutes')
.toISOString(),
category: '_movie',
calendar: '',
location: entry.theatre_location ?? '',
location_coords: entry.theatre_location_coords,
description: `
![${entry.title}](http://image.tmdb.org/t/p/w300/${entry.poster})
### Movie Description:
${entry.overview}
### Theatre Number:
${entry.theatre_number}
### Seat Number:
${entry.theatre_seat}
`,
reference_link: `/movies?show-ticket=${entry.id}`
}))
}

View File

@@ -0,0 +1,11 @@
import { forgeRouter } from '@functions/routes'
import entriesRouter from './routes/entries'
import ticketRouter from './routes/ticket'
import tmdbRouter from './routes/tmdb'
export default forgeRouter({
entries: entriesRouter,
ticket: ticketRouter,
tmdb: tmdbRouter
})

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -0,0 +1,253 @@
import { getAPIKey } from '@functions/database'
import { forgeController, forgeRouter } from '@functions/routes'
import z from 'zod'
const list = forgeController
.query()
.description({
en: 'Get all movie entries',
ms: 'Dapatkan semua catatan filem',
'zh-CN': '获取所有电影条目',
'zh-TW': '獲取所有電影條目'
})
.input({
query: z.object({
watched: z
.enum(['true', 'false'])
.optional()
.default('false')
.transform(val => (val === 'true' ? true : false))
})
})
.callback(async ({ pb, query: { watched } }) => {
const entries = await pb.getFullList
.collection('movies__entries')
.filter([
{
field: 'is_watched',
operator: '=',
value: watched
}
])
.execute()
const total = (
await pb.getList
.collection('movies__entries')
.page(1)
.perPage(1)
.execute()
).totalItems
return {
total,
entries: entries.sort((a, b) => {
if (a.is_watched !== b.is_watched) {
return a.is_watched ? 1 : -1 // Unwatched entries come first
}
if (
(a.ticket_number && !b.ticket_number) ||
(!a.ticket_number && b.ticket_number)
) {
return a.ticket_number ? -1 : 1 // Entries with tickets come first
}
if (a.theatre_showtime && b.theatre_showtime) {
return (
new Date(a.theatre_showtime).getTime() -
new Date(b.theatre_showtime).getTime() // Earlier showtimes come first
)
}
return a.title.localeCompare(b.title)
})
}
})
const create = forgeController
.mutation()
.description({
en: 'Create a movie entry from TMDB',
ms: 'Cipta catatan filem daripada TMDB',
'zh-CN': '从 TMDB 创建电影条目',
'zh-TW': '從 TMDB 創建電影條目'
})
.input({
query: z.object({
id: z.string().transform(val => parseInt(val, 10))
})
})
.statusCode(201)
.callback(async ({ pb, query: { id } }) => {
const apiKey = await getAPIKey('tmdb', pb)
if (!apiKey) {
throw new Error('API key not found')
}
const initialData = await pb.getFirstListItem
.collection('movies__entries')
.filter([
{
field: 'tmdb_id',
operator: '=',
value: id
}
])
.execute()
.catch(() => null)
if (initialData) {
throw new Error('Entry already exists')
}
const response = await fetch(`https://api.themoviedb.org/3/movie/${id}`, {
headers: {
Authorization: `Bearer ${apiKey}`
}
})
.then(res => res.json())
.catch(err => {
throw new Error(`Failed to fetch data from TMDB: ${err.message}`)
})
const entryData = {
tmdb_id: response.id,
title: response.title,
original_title: response.original_title,
poster: response.poster_path,
genres: response.genres.map((genre: { name: string }) => genre.name),
duration: response.runtime,
overview: response.overview,
release_date: response.release_date,
countries: response.origin_country,
language: response.original_language
}
return await pb.create
.collection('movies__entries')
.data(entryData)
.execute()
})
const update = forgeController
.mutation()
.description({
en: 'Update movie entry with the latest data from TMDB',
ms: 'Kemas kini catatan filem dengan data terkini daripada TMDB',
'zh-CN': '使用 TMDB 的最新数据更新电影条目',
'zh-TW': '使用 TMDB 的最新資料更新電影條目'
})
.input({
query: z.object({
id: z.string()
})
})
.existenceCheck('query', {
id: 'movies__entries'
})
.callback(async ({ pb, query: { id } }) => {
const apiKey = await getAPIKey('tmdb', pb)
if (!apiKey) {
throw new Error('API key not found')
}
const movieEntry = await pb.getOne
.collection('movies__entries')
.id(id)
.execute()
const response = await fetch(
`https://api.themoviedb.org/3/movie/${movieEntry.tmdb_id}`,
{
headers: {
Authorization: `Bearer ${apiKey}`
}
}
)
.then(res => res.json())
.catch(err => {
throw new Error(`Failed to fetch data from TMDB: ${err.message}`)
})
const entryData = {
tmdb_id: response.id,
title: response.title,
original_title: response.original_title,
poster: response.poster_path,
genres: response.genres.map((genre: { name: string }) => genre.name),
duration: response.runtime,
overview: response.overview,
release_date: response.release_date,
countries: response.origin_country,
language: response.original_language
}
return await pb.update
.collection('movies__entries')
.id(id)
.data(entryData)
.execute()
})
const remove = forgeController
.mutation()
.description({
en: 'Delete a movie entry',
ms: 'Padam catatan filem',
'zh-CN': '删除电影条目',
'zh-TW': '刪除電影條目'
})
.input({
query: z.object({
id: z.string()
})
})
.existenceCheck('query', {
id: 'movies__entries'
})
.statusCode(204)
.callback(({ pb, query: { id } }) =>
pb.delete.collection('movies__entries').id(id).execute()
)
const toggleWatchStatus = forgeController
.mutation()
.description({
en: 'Toggle watch status of a movie entry',
ms: 'Togol status tontonan catatan filem',
'zh-CN': '切换电影条目的观看状态',
'zh-TW': '切換電影條目的觀看狀態'
})
.input({
query: z.object({
id: z.string()
})
})
.existenceCheck('query', {
id: 'movies__entries'
})
.callback(async ({ pb, query: { id } }) => {
const entry = await pb.getOne.collection('movies__entries').id(id).execute()
return await pb.update
.collection('movies__entries')
.id(id)
.data({
is_watched: !entry.is_watched,
watch_date: !entry.is_watched
? entry.theatre_showtime || new Date().toISOString()
: null
})
.execute()
})
export default forgeRouter({
list,
create,
update,
remove,
toggleWatchStatus
})

View File

@@ -0,0 +1,80 @@
import { forgeController, forgeRouter } from '@functions/routes'
import { Location } from '@lib/locations/typescript/location.types'
import { SCHEMAS } from '@schema'
import z from 'zod'
const update = forgeController
.mutation()
.description({
en: 'Update ticket information for a movie entry',
ms: 'Kemas kini maklumat tiket untuk catatan filem',
'zh-CN': '更新电影条目的票务信息',
'zh-TW': '更新電影條目的票務資訊'
})
.input({
query: z.object({
id: z.string()
}),
body: SCHEMAS.movies.entries.schema
.pick({
ticket_number: true,
theatre_number: true,
theatre_seat: true
})
.extend({
theatre_showtime: z.string().optional(),
theatre_location: Location.optional()
})
})
.existenceCheck('query', {
id: 'movies__entries'
})
.callback(({ pb, query: { id }, body }) => {
const finalData = {
...body,
theatre_location: body.theatre_location?.name,
theatre_location_coords: {
lat: body.theatre_location?.location.latitude || 0,
lon: body.theatre_location?.location.longitude || 0
}
}
return pb.update
.collection('movies__entries')
.id(id)
.data(finalData)
.execute()
})
const clear = forgeController
.mutation()
.description({
en: 'Clear ticket information for a movie entry',
ms: 'Kosongkan maklumat tiket untuk catatan filem',
'zh-CN': '清除电影条目的票务信息',
'zh-TW': '清除電影條目的票務資訊'
})
.input({
query: z.object({
id: z.string()
})
})
.existenceCheck('query', {
id: 'movies__entries'
})
.statusCode(204)
.callback(({ pb, query: { id } }) =>
pb.update
.collection('movies__entries')
.id(id)
.data({
ticket_number: '',
theatre_location: '',
theatre_number: '',
theatre_seat: '',
theatre_showtime: ''
})
.execute()
)
export default forgeRouter({ update, clear })

View File

@@ -0,0 +1,84 @@
import { getAPIKey } from '@functions/database'
import { forgeController, forgeRouter } from '@functions/routes'
import z from 'zod'
export interface TMDBSearchResult {
adult: boolean
backdrop_path: string
genre_ids: number[]
existed: boolean
id: number
original_language: string
original_title: string
overview: string
popularity: number
poster_path: string
release_date: string
title: string
video: boolean
vote_average: number
vote_count: number
}
const search = forgeController
.query()
.description({
en: 'Search movies using TMDB API',
ms: 'Cari filem menggunakan API TMDB',
'zh-CN': '使用 TMDB API 搜索电影',
'zh-TW': '使用 TMDB API 搜尋電影'
})
.input({
query: z.object({
q: z.string().min(1, 'Query must not be empty'),
page: z
.string()
.optional()
.default('1')
.transform(val => parseInt(val) || 1)
})
})
.callback(async ({ pb, query: { q, page } }) => {
const apiKey = await getAPIKey('tmdb', pb)
if (!apiKey) {
throw new Error('API key not found')
}
const url = `https://api.themoviedb.org/3/search/movie?query=${decodeURIComponent(
q
)}&page=${page}`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`
}
}).then(res => res.json())
const allIds = await pb.getFullList
.collection('movies__entries')
.filter([
{
combination: '||',
filters: response.results.map((entry: { id: number }) => ({
field: 'tmdb_id',
operator: '=',
value: entry.id
}))
}
])
.execute()
response.results.forEach((entry: any) => {
entry.existed = allIds.some(e => e.tmdb_id === entry.id)
})
return response as {
page: number
results: TMDBSearchResult[]
total_pages: number
total_results: number
}
})
export default forgeRouter({ search })

View File

@@ -0,0 +1,257 @@
import z from 'zod'
const moviesSchemas = {
entries: {
schema: z.object({
tmdb_id: z.number(),
title: z.string(),
original_title: z.string(),
poster: z.string(),
genres: z.any(),
duration: z.number(),
overview: z.string(),
countries: z.any(),
language: z.string(),
release_date: z.string(),
watch_date: z.string(),
ticket_number: z.string(),
theatre_seat: z.string(),
theatre_showtime: z.string(),
theatre_location: z.string(),
theatre_location_coords: z.object({ lat: z.number(), lon: z.number() }),
theatre_number: z.string(),
is_watched: z.boolean()
}),
raw: {
listRule: '@request.auth.id != ""',
viewRule: '@request.auth.id != ""',
createRule: '@request.auth.id != ""',
updateRule: '@request.auth.id != ""',
deleteRule: '@request.auth.id != ""',
name: 'movies__entries',
type: 'base',
fields: [
{
autogeneratePattern: '[a-z0-9]{15}',
hidden: false,
max: 15,
min: 15,
name: 'id',
pattern: '^[a-z0-9]+$',
presentable: false,
primaryKey: true,
required: true,
system: true,
type: 'text'
},
{
hidden: false,
max: null,
min: null,
name: 'tmdb_id',
onlyInt: false,
presentable: false,
required: false,
system: false,
type: 'number'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'title',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'original_title',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'poster',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
hidden: false,
maxSize: 0,
name: 'genres',
presentable: false,
required: false,
system: false,
type: 'json'
},
{
hidden: false,
max: null,
min: null,
name: 'duration',
onlyInt: false,
presentable: false,
required: false,
system: false,
type: 'number'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'overview',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
hidden: false,
maxSize: 0,
name: 'countries',
presentable: false,
required: false,
system: false,
type: 'json'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'language',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
hidden: false,
max: '',
min: '',
name: 'release_date',
presentable: false,
required: false,
system: false,
type: 'date'
},
{
hidden: false,
max: '',
min: '',
name: 'watch_date',
presentable: false,
required: false,
system: false,
type: 'date'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'ticket_number',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'theatre_seat',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
hidden: false,
max: '',
min: '',
name: 'theatre_showtime',
presentable: false,
required: false,
system: false,
type: 'date'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'theatre_location',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
hidden: false,
name: 'theatre_location_coords',
presentable: false,
required: false,
system: false,
type: 'geoPoint'
},
{
autogeneratePattern: '',
hidden: false,
max: 0,
min: 0,
name: 'theatre_number',
pattern: '',
presentable: false,
primaryKey: false,
required: false,
system: false,
type: 'text'
},
{
hidden: false,
name: 'is_watched',
presentable: false,
required: false,
system: false,
type: 'bool'
}
],
indexes: [],
system: false
}
}
}
export default moviesSchemas

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../server/tsconfig.json",
"include": [
"./**/*.ts"
],
"references": [
{
"path": "../../../server/tsconfig.json"
}
]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "./client/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"composite": false,
"paths": {
"@": [
"./client/index"
],
"@/*": [
"./client/*"
]
}
},
"include": [
"./manifest.ts"
]
}