feat: add favorites page and web starring
This commit is contained in:
@@ -5,6 +5,7 @@ import { AlbumDetailPage } from '@/pages/album-detail-page'
|
||||
import { ArtistsPage } from '@/pages/artists-page'
|
||||
import { ArtistDetailPage } from '@/pages/artist-detail-page'
|
||||
import { EmptyStatePage } from '@/pages/empty-state-page'
|
||||
import { FavoritesPage } from '@/pages/favorites-page'
|
||||
import { HomePage } from '@/pages/home-page'
|
||||
import { LoginPage } from '@/pages/login-page'
|
||||
import { PlaylistDetailPage } from '@/pages/playlist-detail-page'
|
||||
@@ -29,7 +30,7 @@ export default function App() {
|
||||
<Route path="/albums" element={<AlbumsPage />} />
|
||||
<Route path="/albums/:id" element={<AlbumDetailPage />} />
|
||||
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} />
|
||||
<Route path="/favorites" element={<EmptyStatePage compact title="Вы еще не добавили песни в избранное!" />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetailPage />} />
|
||||
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
|
||||
|
||||
60
apps/web/src/components/favorite-toggle.tsx
Normal file
60
apps/web/src/components/favorite-toggle.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { starFavorites, unstarFavorites } from '@/lib/api'
|
||||
|
||||
type FavoriteToggleProps = {
|
||||
entityType: 'track' | 'album' | 'artist'
|
||||
entityId: string
|
||||
active: boolean
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FavoriteToggle({
|
||||
entityType,
|
||||
entityId,
|
||||
active,
|
||||
size = 18,
|
||||
className = '',
|
||||
}: FavoriteToggleProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload =
|
||||
entityType === 'track'
|
||||
? { trackIds: [entityId] }
|
||||
: entityType === 'album'
|
||||
? { albumIds: [entityId] }
|
||||
: { artistIds: [entityId] }
|
||||
|
||||
return active ? unstarFavorites(payload) : starFavorites(payload)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['favorites'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['tracks'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['albums'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['artists'] }),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className || 'text-slate-400 transition hover:text-white'}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
mutation.mutate()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Heart
|
||||
fill={active ? 'currentColor' : 'none'}
|
||||
size={size}
|
||||
strokeWidth={active ? 1.9 : 1.7}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -64,6 +64,12 @@ export type HomePayload = {
|
||||
artists: Artist[]
|
||||
}
|
||||
|
||||
export type FavoritesPayload = {
|
||||
artists: Artist[]
|
||||
albums: Album[]
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export type PlaylistSummary = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -190,6 +196,32 @@ export async function deletePlaylist(id: string) {
|
||||
await request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchFavorites() {
|
||||
return request<FavoritesPayload>('/api/favorites')
|
||||
}
|
||||
|
||||
export async function starFavorites(input: {
|
||||
trackIds?: string[]
|
||||
albumIds?: string[]
|
||||
artistIds?: string[]
|
||||
}) {
|
||||
return request<FavoritesPayload>('/api/favorites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function unstarFavorites(input: {
|
||||
trackIds?: string[]
|
||||
albumIds?: string[]
|
||||
artistIds?: string[]
|
||||
}) {
|
||||
return request<FavoritesPayload>('/api/favorites', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export function coverArtUrl(id: string) {
|
||||
const token = useSessionStore.getState().token
|
||||
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { coverArtUrl, fetchAlbum } from '@/lib/api'
|
||||
import { coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function AlbumDetailPage() {
|
||||
@@ -12,6 +13,10 @@ export function AlbumDetailPage() {
|
||||
queryKey: ['album', id],
|
||||
queryFn: () => fetchAlbum(id),
|
||||
})
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
queryFn: fetchFavorites,
|
||||
})
|
||||
|
||||
const album = albumQuery.data
|
||||
|
||||
@@ -20,6 +25,8 @@ export function AlbumDetailPage() {
|
||||
}
|
||||
|
||||
const totalDuration = album.tracks.reduce((sum, track) => sum + track.durationSeconds, 0)
|
||||
const favoriteAlbumIds = new Set((favoritesQuery.data?.albums ?? []).map((item) => item.id))
|
||||
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id))
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
|
||||
@@ -50,9 +57,7 @@ export function AlbumDetailPage() {
|
||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||
<Shuffle size={24} />
|
||||
</button>
|
||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||
<Heart size={24} />
|
||||
</button>
|
||||
<FavoriteToggle active={favoriteAlbumIds.has(album.id)} className="text-slate-300 transition hover:text-white" entityId={album.id} entityType="album" size={24} />
|
||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||
<MoreVertical size={24} />
|
||||
</button>
|
||||
@@ -93,7 +98,9 @@ export function AlbumDetailPage() {
|
||||
<div>
|
||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
||||
</div>
|
||||
<div className="text-slate-500">♡</div>
|
||||
<div className="grid place-items-center text-slate-500">
|
||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||
import { coverArtUrl, fetchArtist } from '@/lib/api'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { coverArtUrl, fetchArtist, fetchFavorites } from '@/lib/api'
|
||||
|
||||
export function ArtistDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const artistQuery = useQuery({
|
||||
queryKey: ['artist', id],
|
||||
queryFn: () => fetchArtist(id),
|
||||
})
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
queryFn: fetchFavorites,
|
||||
})
|
||||
|
||||
const artist = artistQuery.data
|
||||
|
||||
@@ -16,6 +22,8 @@ export function ArtistDetailPage() {
|
||||
return <div className="text-slate-400">Загрузка исполнителя...</div>
|
||||
}
|
||||
|
||||
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
|
||||
<div className="flex min-h-[300px] items-end gap-5 bg-[linear-gradient(180deg,rgba(189,16,37,0.78),rgba(125,15,29,0.92))] px-8 py-6">
|
||||
@@ -41,9 +49,7 @@ export function ArtistDetailPage() {
|
||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||
<Shuffle size={24} />
|
||||
</button>
|
||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||
<Heart size={24} />
|
||||
</button>
|
||||
<FavoriteToggle active={favoriteArtistIds.has(artist.id)} className="text-slate-300 transition hover:text-white" entityId={artist.id} entityType="artist" size={24} />
|
||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||
<MoreVertical size={24} />
|
||||
</button>
|
||||
@@ -58,10 +64,10 @@ export function ArtistDetailPage() {
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||||
{artist.albums.map((album) => (
|
||||
<article key={album.id}>
|
||||
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
|
||||
<button className="aspect-square w-full overflow-hidden rounded-[8px] bg-[#232d42]" onClick={() => navigate(`/albums/${album.id}`)} type="button">
|
||||
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div>
|
||||
</button>
|
||||
<button className="mt-3 line-clamp-1 text-left text-[1.08rem] font-semibold text-white" onClick={() => navigate(`/albums/${album.id}`)} type="button">{album.title}</button>
|
||||
<div className="text-base text-slate-400">{artist.name}</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
132
apps/web/src/pages/favorites-page.tsx
Normal file
132
apps/web/src/pages/favorites-page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function FavoritesPage() {
|
||||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||||
const playTrack = usePlayerStore((state) => state.playTrack)
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
queryFn: fetchFavorites,
|
||||
})
|
||||
|
||||
const favorites = favoritesQuery.data
|
||||
const tracks = favorites?.tracks ?? []
|
||||
const albums = favorites?.albums ?? []
|
||||
const artists = favorites?.artists ?? []
|
||||
|
||||
if (tracks.length === 0 && albums.length === 0 && artists.length === 0) {
|
||||
return (
|
||||
<div className="grid min-h-[520px] place-items-center rounded-[16px] border border-dashed border-[#24314f] bg-[#121b2e]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-semibold text-white">Пока в избранном пусто</div>
|
||||
<div className="mt-4 text-lg text-slate-400">Отмечай треки, альбомы и исполнителей сердечком.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{tracks.length > 0 ? (
|
||||
<section className="space-y-4">
|
||||
<div className="text-[2rem] font-semibold tracking-tight text-white">Любимые треки</div>
|
||||
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
|
||||
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
|
||||
<div>#</div>
|
||||
<div>Название</div>
|
||||
<div>Альбом</div>
|
||||
<div>◷</div>
|
||||
<div>♡</div>
|
||||
</div>
|
||||
{tracks.map((track, index) => (
|
||||
<button
|
||||
key={track.id}
|
||||
className="grid w-full grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 border-t border-[#1f2940] px-4 py-3 text-left transition hover:bg-[#172237]"
|
||||
onClick={() => {
|
||||
setQueue(tracks, index)
|
||||
playTrack(track, tracks)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="text-lg text-slate-200">{index + 1}</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-[6px] bg-[#303b4d]">
|
||||
{track.coverArtId ? <img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} /> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[1.02rem] text-white">{track.title}</div>
|
||||
<div className="truncate text-base text-slate-400">{track.artistName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
|
||||
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
|
||||
<div className="grid place-items-center">
|
||||
<FavoriteToggle active entityId={track.id} entityType="track" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{albums.length > 0 ? (
|
||||
<section>
|
||||
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Любимые альбомы</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||||
{albums.map((album) => (
|
||||
<article key={album.id}>
|
||||
<Link className="block aspect-square overflow-hidden rounded-[8px] bg-[#232d42]" to={`/albums/${album.id}`}>
|
||||
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
|
||||
</Link>
|
||||
<div className="mt-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link className="line-clamp-1 block text-[1.08rem] font-semibold text-white hover:underline" to={`/albums/${album.id}`}>
|
||||
{album.title}
|
||||
</Link>
|
||||
<div className="text-base text-slate-400">{album.artistName}</div>
|
||||
</div>
|
||||
<FavoriteToggle active entityId={album.id} entityType="album" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{artists.length > 0 ? (
|
||||
<section>
|
||||
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Любимые исполнители</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{artists.map((artist) => (
|
||||
<Link
|
||||
key={artist.id}
|
||||
className="flex items-center justify-between rounded-[12px] border border-[#24314f] bg-[#121b2e] px-5 py-4 transition hover:border-[#39527e] hover:bg-[#16233b]"
|
||||
to={`/artists/${artist.id}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-[#303b4d]">
|
||||
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-medium text-white">{artist.name}</div>
|
||||
<div className="text-sm text-slate-400">{artist.albumCount} альбомов</div>
|
||||
</div>
|
||||
</div>
|
||||
<FavoriteToggle active entityId={artist.id} entityType="artist" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number) {
|
||||
const minutes = Math.floor(durationSeconds / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Heart, Search } from 'lucide-react'
|
||||
import { coverArtUrl, fetchTracks } from '@/lib/api'
|
||||
import { Search } from 'lucide-react'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function TracksPage() {
|
||||
@@ -10,8 +11,13 @@ export function TracksPage() {
|
||||
queryKey: ['tracks'],
|
||||
queryFn: fetchTracks,
|
||||
})
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
queryFn: fetchFavorites,
|
||||
})
|
||||
|
||||
const tracks = tracksQuery.data?.items ?? []
|
||||
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((track) => track.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -58,7 +64,7 @@ export function TracksPage() {
|
||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
||||
</div>
|
||||
<div className="grid place-items-center text-slate-500">
|
||||
<Heart size={16} />
|
||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -64,6 +64,9 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
private.Get("/tracks", application.tracks)
|
||||
private.Get("/tracks/{id}", application.trackByID)
|
||||
private.Get("/search", application.search)
|
||||
private.Get("/favorites", application.favorites)
|
||||
private.Post("/favorites", application.starFavorites)
|
||||
private.Delete("/favorites", application.unstarFavorites)
|
||||
private.Get("/playlists", application.playlistsList)
|
||||
private.Post("/playlists", application.createPlaylist)
|
||||
private.Get("/playlists/{id}", application.playlistByID)
|
||||
@@ -234,6 +237,62 @@ func (a app) search(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
|
||||
func (a app) favorites(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUserFromContext(r)
|
||||
results, err := a.library.Starred(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
|
||||
func (a app) starFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUserFromContext(r)
|
||||
var payload struct {
|
||||
TrackIDs []string `json:"trackIds"`
|
||||
AlbumIDs []string `json:"albumIds"`
|
||||
ArtistIDs []string `json:"artistIds"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if err := a.library.Star(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to star favorites"})
|
||||
return
|
||||
}
|
||||
results, err := a.library.Starred(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
|
||||
func (a app) unstarFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUserFromContext(r)
|
||||
var payload struct {
|
||||
TrackIDs []string `json:"trackIds"`
|
||||
AlbumIDs []string `json:"albumIds"`
|
||||
ArtistIDs []string `json:"artistIds"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if err := a.library.Unstar(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to unstar favorites"})
|
||||
return
|
||||
}
|
||||
results, err := a.library.Starred(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
|
||||
func (a app) playlistsList(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUserFromContext(r)
|
||||
items, err := a.playlists.List(r.Context(), user.ID)
|
||||
|
||||
Reference in New Issue
Block a user