mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-07-01 08:16:35 +00:00
refactor(forgeCLI): massive refactoring
This commit is contained in:
14
apps/lifeforge--movies/client/components/MovieGrid.tsx
Normal file
14
apps/lifeforge--movies/client/components/MovieGrid.tsx
Normal 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
|
||||
285
apps/lifeforge--movies/client/components/MovieItem.tsx
Normal file
285
apps/lifeforge--movies/client/components/MovieItem.tsx
Normal 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
|
||||
14
apps/lifeforge--movies/client/components/MovieList.tsx
Normal file
14
apps/lifeforge--movies/client/components/MovieList.tsx
Normal 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
|
||||
1
apps/lifeforge--movies/client/components/TMDBLogo.svg
Normal file
1
apps/lifeforge--movies/client/components/TMDBLogo.svg
Normal 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 |
177
apps/lifeforge--movies/client/index.tsx
Normal file
177
apps/lifeforge--movies/client/index.tsx
Normal 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
|
||||
138
apps/lifeforge--movies/client/modals/ModifyTicketModal.tsx
Normal file
138
apps/lifeforge--movies/client/modals/ModifyTicketModal.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
133
apps/lifeforge--movies/client/modals/SearchTMDBModal/index.tsx
Normal file
133
apps/lifeforge--movies/client/modals/SearchTMDBModal/index.tsx
Normal 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
|
||||
<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
|
||||
63
apps/lifeforge--movies/client/modals/ShowTicketModal.tsx
Normal file
63
apps/lifeforge--movies/client/modals/ShowTicketModal.tsx
Normal 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
|
||||
49
apps/lifeforge--movies/client/tsconfig.json
Normal file
49
apps/lifeforge--movies/client/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
apps/lifeforge--movies/client/utils/forgeAPI.ts
Normal file
10
apps/lifeforge--movies/client/utils/forgeAPI.ts
Normal 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
|
||||
1
apps/lifeforge--movies/client/vite-env.d.ts
vendored
Normal file
1
apps/lifeforge--movies/client/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
59
apps/lifeforge--movies/locales/en.json
Normal file
59
apps/lifeforge--movies/locales/en.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"title": "Movies",
|
||||
"description": "Your personal log for every movie you’ve 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}}"
|
||||
}
|
||||
}
|
||||
59
apps/lifeforge--movies/locales/ms.json
Normal file
59
apps/lifeforge--movies/locales/ms.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
59
apps/lifeforge--movies/locales/zh-CN.json
Normal file
59
apps/lifeforge--movies/locales/zh-CN.json
Normal 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}}"
|
||||
}
|
||||
}
|
||||
59
apps/lifeforge--movies/locales/zh-TW.json
Normal file
59
apps/lifeforge--movies/locales/zh-TW.json
Normal 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
7
apps/lifeforge--movies/manifest.d.ts
vendored
Normal 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
|
||||
9
apps/lifeforge--movies/manifest.ts
Normal file
9
apps/lifeforge--movies/manifest.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { lazy } from 'react'
|
||||
import type { ModuleConfig } from 'shared'
|
||||
|
||||
export default {
|
||||
routes: {
|
||||
'/': lazy(() => import('@'))
|
||||
},
|
||||
|
||||
} satisfies ModuleConfig
|
||||
47
apps/lifeforge--movies/package.json
Normal file
47
apps/lifeforge--movies/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@lifeforge/lifeforge--movies",
|
||||
"displayName": "Movies Library",
|
||||
"version": "0.1.0",
|
||||
"description": "Your personal log for every movie you’ve 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"
|
||||
}
|
||||
}
|
||||
52
apps/lifeforge--movies/server/events.ts
Normal file
52
apps/lifeforge--movies/server/events.ts
Normal 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: `
|
||||

|
||||
|
||||
### Movie Description:
|
||||
${entry.overview}
|
||||
|
||||
### Theatre Number:
|
||||
${entry.theatre_number}
|
||||
|
||||
### Seat Number:
|
||||
${entry.theatre_seat}
|
||||
`,
|
||||
reference_link: `/movies?show-ticket=${entry.id}`
|
||||
}))
|
||||
}
|
||||
11
apps/lifeforge--movies/server/index.ts
Normal file
11
apps/lifeforge--movies/server/index.ts
Normal 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
|
||||
})
|
||||
3
apps/lifeforge--movies/server/package.json
Normal file
3
apps/lifeforge--movies/server/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
253
apps/lifeforge--movies/server/routes/entries.ts
Normal file
253
apps/lifeforge--movies/server/routes/entries.ts
Normal 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
|
||||
})
|
||||
80
apps/lifeforge--movies/server/routes/ticket.ts
Normal file
80
apps/lifeforge--movies/server/routes/ticket.ts
Normal 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 })
|
||||
84
apps/lifeforge--movies/server/routes/tmdb.ts
Normal file
84
apps/lifeforge--movies/server/routes/tmdb.ts
Normal 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 })
|
||||
257
apps/lifeforge--movies/server/schema.ts
Normal file
257
apps/lifeforge--movies/server/schema.ts
Normal 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
|
||||
11
apps/lifeforge--movies/server/tsconfig.json
Normal file
11
apps/lifeforge--movies/server/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../server/tsconfig.json",
|
||||
"include": [
|
||||
"./**/*.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../server/tsconfig.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
apps/lifeforge--movies/tsconfig.json
Normal file
18
apps/lifeforge--movies/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./client/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"composite": false,
|
||||
"paths": {
|
||||
"@": [
|
||||
"./client/index"
|
||||
],
|
||||
"@/*": [
|
||||
"./client/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./manifest.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user