mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 16:07:47 +00:00
feat: implement core movie streaming features including dashboard, watch history, authentication, and dynamic media pages
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
export type LibraryMovieItem = {
|
||||
source: string
|
||||
slug: string
|
||||
name: string
|
||||
originName?: string
|
||||
thumb?: string
|
||||
poster?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
const favoriteKey = 'kr-phim-favorites'
|
||||
const watchLaterKey = 'kr-phim-watch-later'
|
||||
|
||||
function normalizeLibraryItem(item: any): LibraryMovieItem | null {
|
||||
if (!item?.slug || !item?.name) return null
|
||||
|
||||
return {
|
||||
source: String(item.source || ''),
|
||||
slug: String(item.slug),
|
||||
name: String(item.name),
|
||||
originName: item.originName || item.origin_name || undefined,
|
||||
thumb: item.thumb || undefined,
|
||||
poster: item.poster || undefined,
|
||||
updatedAt: typeof item.updatedAt === 'number'
|
||||
? item.updatedAt
|
||||
: new Date(item.updated_at || Date.now()).getTime(),
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalItems(key: string) {
|
||||
if (!import.meta.client) return []
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key)
|
||||
const items = raw ? JSON.parse(raw) : []
|
||||
|
||||
return Array.isArray(items)
|
||||
? items.map(normalizeLibraryItem).filter(Boolean) as LibraryMovieItem[]
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalItems(key: string, items: LibraryMovieItem[]) {
|
||||
if (!import.meta.client) return
|
||||
window.localStorage.setItem(key, JSON.stringify(items))
|
||||
}
|
||||
|
||||
export function useMovieLibrary() {
|
||||
const { $supabase } = useNuxtApp()
|
||||
const { user } = useSupabaseAuth()
|
||||
|
||||
async function loadItems(key: string, table: string, limit = 30) {
|
||||
const localItems = readLocalItems(key)
|
||||
|
||||
if (user.value) {
|
||||
const { data, error } = await $supabase
|
||||
.from(table)
|
||||
.select('source, slug, name, origin_name, thumb, poster, updated_at')
|
||||
.eq('user_id', user.value.id)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (!error && data) {
|
||||
return data.map(normalizeLibraryItem).filter(Boolean) as LibraryMovieItem[]
|
||||
}
|
||||
}
|
||||
|
||||
return localItems
|
||||
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
async function saveItem(key: string, table: string, item: LibraryMovieItem) {
|
||||
const normalizedItem = normalizeLibraryItem({
|
||||
...item,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
|
||||
if (!normalizedItem) return false
|
||||
|
||||
const nextItems = [
|
||||
normalizedItem,
|
||||
...readLocalItems(key).filter((savedItem) => !(savedItem.slug === normalizedItem.slug && savedItem.source === normalizedItem.source)),
|
||||
].slice(0, 50)
|
||||
|
||||
writeLocalItems(key, nextItems)
|
||||
|
||||
if (user.value) {
|
||||
await $supabase
|
||||
.from(table)
|
||||
.upsert({
|
||||
user_id: user.value.id,
|
||||
source: normalizedItem.source,
|
||||
slug: normalizedItem.slug,
|
||||
name: normalizedItem.name,
|
||||
origin_name: normalizedItem.originName || null,
|
||||
thumb: normalizedItem.thumb || null,
|
||||
poster: normalizedItem.poster || null,
|
||||
updated_at: new Date(normalizedItem.updatedAt || Date.now()).toISOString(),
|
||||
}, {
|
||||
onConflict: 'user_id,source,slug',
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function removeItem(key: string, table: string, item: LibraryMovieItem) {
|
||||
const nextItems = readLocalItems(key)
|
||||
.filter((savedItem) => !(savedItem.slug === item.slug && savedItem.source === item.source))
|
||||
writeLocalItems(key, nextItems)
|
||||
|
||||
if (user.value) {
|
||||
await $supabase
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq('user_id', user.value.id)
|
||||
.eq('source', item.source)
|
||||
.eq('slug', item.slug)
|
||||
}
|
||||
}
|
||||
|
||||
async function isSaved(key: string, table: string, item: LibraryMovieItem) {
|
||||
const localSaved = readLocalItems(key)
|
||||
.some((savedItem) => savedItem.slug === item.slug && savedItem.source === item.source)
|
||||
|
||||
if (!user.value) return localSaved
|
||||
|
||||
const { data, error } = await $supabase
|
||||
.from(table)
|
||||
.select('slug')
|
||||
.eq('user_id', user.value.id)
|
||||
.eq('source', item.source)
|
||||
.eq('slug', item.slug)
|
||||
.maybeSingle()
|
||||
|
||||
return error ? localSaved : Boolean(data)
|
||||
}
|
||||
|
||||
return {
|
||||
loadFavorites: (limit?: number) => loadItems(favoriteKey, 'favorite_movies', limit),
|
||||
saveFavorite: (item: LibraryMovieItem) => saveItem(favoriteKey, 'favorite_movies', item),
|
||||
removeFavorite: (item: LibraryMovieItem) => removeItem(favoriteKey, 'favorite_movies', item),
|
||||
isFavorite: (item: LibraryMovieItem) => isSaved(favoriteKey, 'favorite_movies', item),
|
||||
saveWatchLater: (item: LibraryMovieItem) => saveItem(watchLaterKey, 'watch_later_movies', item),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
|
||||
let authListenerStarted = false
|
||||
|
||||
export function useSupabaseAuth() {
|
||||
const { $supabase } = useNuxtApp()
|
||||
const user = useState<User | null>('supabase-user', () => null)
|
||||
const loading = useState('supabase-auth-loading', () => false)
|
||||
|
||||
async function initAuth() {
|
||||
if (!import.meta.client || authListenerStarted) return
|
||||
|
||||
authListenerStarted = true
|
||||
loading.value = true
|
||||
|
||||
const { data } = await $supabase.auth.getSession()
|
||||
user.value = data.session?.user ?? null
|
||||
loading.value = false
|
||||
|
||||
$supabase.auth.onAuthStateChange((_event, session) => {
|
||||
user.value = session?.user ?? null
|
||||
})
|
||||
}
|
||||
|
||||
function authRedirectUrl() {
|
||||
const redirectTo = import.meta.client ? window.location.origin : undefined
|
||||
return redirectTo
|
||||
}
|
||||
|
||||
async function signInWithEmail(email: string) {
|
||||
return $supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: authRedirectUrl(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function signInWithPassword(email: string, password: string) {
|
||||
return $supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
async function signUpWithPassword(email: string, password: string) {
|
||||
return $supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: authRedirectUrl(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function signInWithGoogle() {
|
||||
return $supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: authRedirectUrl(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPassword(email: string) {
|
||||
return $supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: authRedirectUrl(),
|
||||
})
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
return $supabase.auth.signOut()
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
initAuth,
|
||||
signInWithEmail,
|
||||
signInWithPassword,
|
||||
signUpWithPassword,
|
||||
signInWithGoogle,
|
||||
resetPassword,
|
||||
signOut,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
export type WatchHistoryItem = {
|
||||
source: string
|
||||
slug: string
|
||||
name: string
|
||||
originName?: string
|
||||
thumb?: string
|
||||
poster?: string
|
||||
episodeName?: string
|
||||
episodeIndex?: number
|
||||
serverIndex?: number
|
||||
progressSeconds?: number
|
||||
durationSeconds?: number
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
const watchHistoryKey = 'kr-phim-watch-history'
|
||||
|
||||
function normalizeHistoryItem(item: any): WatchHistoryItem | null {
|
||||
const slug = item?.slug
|
||||
const name = item?.name
|
||||
|
||||
if (!slug || !name) return null
|
||||
|
||||
return {
|
||||
source: String(item.source || ''),
|
||||
slug: String(slug),
|
||||
name: String(name),
|
||||
originName: item.originName || item.origin_name || undefined,
|
||||
thumb: item.thumb || undefined,
|
||||
poster: item.poster || undefined,
|
||||
episodeName: item.episodeName || item.episode_name || undefined,
|
||||
episodeIndex: Number(item.episodeIndex ?? item.episode_index ?? 0),
|
||||
serverIndex: Number(item.serverIndex ?? item.server_index ?? 0),
|
||||
progressSeconds: Math.max(Number(item.progressSeconds ?? item.progress_seconds ?? 0), 0),
|
||||
durationSeconds: Math.max(Number(item.durationSeconds ?? item.duration_seconds ?? 0), 0),
|
||||
updatedAt: typeof item.updatedAt === 'number'
|
||||
? item.updatedAt
|
||||
: new Date(item.updated_at || Date.now()).getTime(),
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalHistory() {
|
||||
if (!import.meta.client) return []
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(watchHistoryKey)
|
||||
const history = raw ? JSON.parse(raw) : []
|
||||
|
||||
return Array.isArray(history)
|
||||
? history
|
||||
.map(normalizeHistoryItem)
|
||||
.filter(Boolean) as WatchHistoryItem[]
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalHistory(items: WatchHistoryItem[]) {
|
||||
if (!import.meta.client) return
|
||||
window.localStorage.setItem(watchHistoryKey, JSON.stringify(items))
|
||||
}
|
||||
|
||||
export function useWatchHistory() {
|
||||
const { $supabase } = useNuxtApp()
|
||||
const { user } = useSupabaseAuth()
|
||||
|
||||
async function loadWatchHistory(limit = 12) {
|
||||
const localItems = readLocalHistory()
|
||||
|
||||
if (user.value) {
|
||||
if (localItems.length) {
|
||||
await $supabase
|
||||
.from('watch_history')
|
||||
.upsert(localItems.slice(0, 20).map((item) => ({
|
||||
user_id: user.value?.id,
|
||||
source: item.source,
|
||||
slug: item.slug,
|
||||
name: item.name,
|
||||
origin_name: item.originName || null,
|
||||
thumb: item.thumb || null,
|
||||
poster: item.poster || null,
|
||||
episode_name: item.episodeName || null,
|
||||
episode_index: item.episodeIndex || 0,
|
||||
server_index: item.serverIndex || 0,
|
||||
progress_seconds: Math.floor(item.progressSeconds || 0),
|
||||
duration_seconds: Math.floor(item.durationSeconds || 0),
|
||||
updated_at: new Date(item.updatedAt || Date.now()).toISOString(),
|
||||
})), {
|
||||
onConflict: 'user_id,source,slug',
|
||||
})
|
||||
}
|
||||
|
||||
const { data, error } = await $supabase
|
||||
.from('watch_history')
|
||||
.select('source, slug, name, origin_name, thumb, poster, episode_name, episode_index, server_index, progress_seconds, duration_seconds, updated_at')
|
||||
.eq('user_id', user.value.id)
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (!error && data) {
|
||||
return data
|
||||
.map(normalizeHistoryItem)
|
||||
.filter(Boolean) as WatchHistoryItem[]
|
||||
}
|
||||
}
|
||||
|
||||
return localItems
|
||||
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
async function saveWatchHistory(item: WatchHistoryItem) {
|
||||
const normalizedItem = normalizeHistoryItem({
|
||||
...item,
|
||||
updatedAt: item.updatedAt || Date.now(),
|
||||
})
|
||||
|
||||
if (!normalizedItem) return
|
||||
|
||||
const localItems = [
|
||||
normalizedItem,
|
||||
...readLocalHistory().filter((historyItem) => !(historyItem.slug === normalizedItem.slug && historyItem.source === normalizedItem.source)),
|
||||
].slice(0, 20)
|
||||
|
||||
writeLocalHistory(localItems)
|
||||
|
||||
if (!user.value) return
|
||||
|
||||
await $supabase
|
||||
.from('watch_history')
|
||||
.upsert({
|
||||
user_id: user.value.id,
|
||||
source: normalizedItem.source,
|
||||
slug: normalizedItem.slug,
|
||||
name: normalizedItem.name,
|
||||
origin_name: normalizedItem.originName || null,
|
||||
thumb: normalizedItem.thumb || null,
|
||||
poster: normalizedItem.poster || null,
|
||||
episode_name: normalizedItem.episodeName || null,
|
||||
episode_index: normalizedItem.episodeIndex || 0,
|
||||
server_index: normalizedItem.serverIndex || 0,
|
||||
progress_seconds: Math.floor(normalizedItem.progressSeconds || 0),
|
||||
duration_seconds: Math.floor(normalizedItem.durationSeconds || 0),
|
||||
updated_at: new Date(normalizedItem.updatedAt || Date.now()).toISOString(),
|
||||
}, {
|
||||
onConflict: 'user_id,source,slug',
|
||||
})
|
||||
}
|
||||
|
||||
async function clearWatchHistory() {
|
||||
if (import.meta.client) window.localStorage.removeItem(watchHistoryKey)
|
||||
|
||||
if (user.value) {
|
||||
await $supabase
|
||||
.from('watch_history')
|
||||
.delete()
|
||||
.eq('user_id', user.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadWatchHistory,
|
||||
saveWatchHistory,
|
||||
clearWatchHistory,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user