mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-28 20:41:19 +00:00
Initial frontend commit for vods.
This commit is contained in:
parent
84aa631196
commit
386a03381c
6 changed files with 651 additions and 1 deletions
|
|
@ -16,6 +16,7 @@ import DVR from './pages/DVR';
|
|||
import Settings from './pages/Settings';
|
||||
import Users from './pages/Users';
|
||||
import LogosPage from './pages/Logos';
|
||||
import VODsPage from './pages/VODs';
|
||||
import useAuthStore from './store/auth';
|
||||
import FloatingVideo from './components/FloatingVideo';
|
||||
import { WebsocketProvider } from './WebSocket';
|
||||
|
|
@ -135,6 +136,7 @@ const App = () => {
|
|||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/logos" element={<LogosPage />} />
|
||||
<Route path="/vods" element={<VODsPage />} />
|
||||
</>
|
||||
) : (
|
||||
<Route path="/login" element={<Login needsSuperuser />} />
|
||||
|
|
|
|||
|
|
@ -1689,4 +1689,76 @@ export default class API {
|
|||
errorNotification('Failed to retrieve streams by IDs', e);
|
||||
}
|
||||
}
|
||||
|
||||
// VOD Methods
|
||||
static async getVODs(params = {}) {
|
||||
try {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
const response = await request(`${host}/api/vod/vods/?${searchParams.toString()}`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve VODs', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getVODCategories() {
|
||||
try {
|
||||
const response = await request(`${host}/api/vod/categories/`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve VOD categories', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getSeries(params = {}) {
|
||||
try {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
const response = await request(`${host}/api/vod/series/?${searchParams.toString()}`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve series', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getSeriesEpisodes(seriesId, params = {}) {
|
||||
try {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
const response = await request(`${host}/api/vod/series/${seriesId}/episodes/?${searchParams.toString()}`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve series episodes', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getVODConnections() {
|
||||
try {
|
||||
const response = await request(`${host}/api/vod/connections/`);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve VOD connections', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async refreshVODContent(accountId) {
|
||||
try {
|
||||
const response = await request(`${host}/api/m3u/accounts/${accountId}/refresh-vod/`, {
|
||||
method: 'POST'
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to refresh VOD content', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateVODPosition(vodUuid, clientId, position) {
|
||||
try {
|
||||
const response = await request(`${host}/proxy/vod/stream/${vodUuid}/position/`, {
|
||||
method: 'POST',
|
||||
body: { client_id: clientId, position }
|
||||
});
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update playback position', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,13 +99,18 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
path: '/channels',
|
||||
badge: `(${Object.keys(channels).length})`,
|
||||
},
|
||||
{
|
||||
label: 'VODs',
|
||||
path: '/vods',
|
||||
icon: <Video size={20} />,
|
||||
},
|
||||
{
|
||||
label: 'M3U & EPG Manager',
|
||||
icon: <Play size={20} />,
|
||||
path: '/sources',
|
||||
},
|
||||
{ label: 'TV Guide', icon: <LayoutGrid size={20} />, path: '/guide' },
|
||||
{ label: 'DVR', icon: <Video size={20} />, path: '/dvr' },
|
||||
{ label: 'DVR', icon: <Database size={20} />, path: '/dvr' },
|
||||
{ label: 'Stats', icon: <ChartLine size={20} />, path: '/stats' },
|
||||
{
|
||||
label: 'Users',
|
||||
|
|
|
|||
|
|
@ -303,3 +303,25 @@ export const REGION_CHOICES = [
|
|||
{ value: 'zm', label: 'ZM' },
|
||||
{ value: 'zw', label: 'ZW' },
|
||||
];
|
||||
|
||||
export const VOD_TYPES = {
|
||||
MOVIE: 'movie',
|
||||
EPISODE: 'episode'
|
||||
};
|
||||
|
||||
export const VOD_FILTERS = {
|
||||
ALL: 'all',
|
||||
MOVIES: 'movies',
|
||||
SERIES: 'series'
|
||||
};
|
||||
|
||||
export const VOD_SORT_OPTIONS = [
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
{ value: 'created_at', label: 'Date Added' },
|
||||
{ value: 'rating', label: 'Rating' }
|
||||
];
|
||||
|
||||
export const CONTAINER_EXTENSIONS = [
|
||||
'mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'mpg'
|
||||
];
|
||||
|
|
|
|||
418
frontend/src/pages/VODs.jsx
Normal file
418
frontend/src/pages/VODs.jsx
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Text,
|
||||
Title,
|
||||
Select,
|
||||
TextInput,
|
||||
Pagination,
|
||||
Badge,
|
||||
Grid,
|
||||
Loader,
|
||||
Stack,
|
||||
SegmentedControl,
|
||||
ActionIcon,
|
||||
Modal
|
||||
} from '@mantine/core';
|
||||
import { Search, Play, Calendar, Clock, Star } from 'lucide-react';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import useVODStore from '../store/useVODStore';
|
||||
import useVideoStore from '../store/useVideoStore';
|
||||
|
||||
const VODCard = ({ vod, onClick }) => {
|
||||
const isEpisode = vod.type === 'episode';
|
||||
|
||||
const formatDuration = (minutes) => {
|
||||
if (!minutes) return '';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const getDisplayTitle = () => {
|
||||
if (isEpisode && vod.series) {
|
||||
const seasonEp = vod.season_number && vod.episode_number
|
||||
? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}`
|
||||
: '';
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Text size="sm" color="dimmed">{vod.series.name}</Text>
|
||||
<Text weight={500}>{seasonEp} - {vod.name}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
return <Text weight={500}>{vod.name}</Text>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
|
||||
onClick={() => onClick(vod)}
|
||||
>
|
||||
<Card.Section>
|
||||
<Box style={{ position: 'relative', height: 200 }}>
|
||||
{vod.logo?.url ? (
|
||||
<Image
|
||||
src={vod.logo.url}
|
||||
height={200}
|
||||
alt={vod.name}
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
height: 200,
|
||||
backgroundColor: '#404040',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Play size={48} color="#666" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ActionIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(vod);
|
||||
}}
|
||||
>
|
||||
<Play size={16} color="white" />
|
||||
</ActionIcon>
|
||||
|
||||
<Badge
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: 8
|
||||
}}
|
||||
color={isEpisode ? 'blue' : 'green'}
|
||||
>
|
||||
{isEpisode ? 'Episode' : 'Movie'}
|
||||
</Badge>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing={8} mt="md">
|
||||
{getDisplayTitle()}
|
||||
|
||||
<Group spacing={16}>
|
||||
{vod.year && (
|
||||
<Group spacing={4}>
|
||||
<Calendar size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">{vod.year}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vod.duration && (
|
||||
<Group spacing={4}>
|
||||
<Clock size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">{formatDuration(vod.duration)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vod.rating && (
|
||||
<Group spacing={4}>
|
||||
<Star size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">{vod.rating}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{vod.genre && (
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{vod.genre}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SeriesCard = ({ series, onClick }) => {
|
||||
return (
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', backgroundColor: '#27272A' }}
|
||||
onClick={() => onClick(series)}
|
||||
>
|
||||
<Card.Section>
|
||||
<Box style={{ position: 'relative', height: 200 }}>
|
||||
{series.logo?.url ? (
|
||||
<Image
|
||||
src={series.logo.url}
|
||||
height={200}
|
||||
alt={series.name}
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
height: 200,
|
||||
backgroundColor: '#404040',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Play size={48} color="#666" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing={8} mt="md">
|
||||
<Text weight={500}>{series.name}</Text>
|
||||
|
||||
<Group spacing={16}>
|
||||
{series.year && (
|
||||
<Group spacing={4}>
|
||||
<Calendar size={14} color="#666" />
|
||||
<Text size="xs" color="dimmed">{series.year}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Text size="xs" color="dimmed">
|
||||
{series.episode_count} episodes
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{series.genre && (
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{series.genre}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SeriesModal = ({ series, opened, onClose }) => {
|
||||
const { fetchSeriesEpisodes, vods, loading } = useVODStore();
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && series) {
|
||||
fetchSeriesEpisodes(series.id);
|
||||
}
|
||||
}, [opened, series, fetchSeriesEpisodes]);
|
||||
|
||||
const episodes = Object.values(vods).filter(
|
||||
vod => vod.type === 'episode' && vod.series?.id === series?.id
|
||||
).sort((a, b) => {
|
||||
if (a.season_number !== b.season_number) {
|
||||
return (a.season_number || 0) - (b.season_number || 0);
|
||||
}
|
||||
return (a.episode_number || 0) - (b.episode_number || 0);
|
||||
});
|
||||
|
||||
const handlePlayEpisode = (episode) => {
|
||||
const streamUrl = `${window.location.origin}${episode.stream_url}`;
|
||||
showVideo(streamUrl);
|
||||
};
|
||||
|
||||
if (!series) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={series.name}
|
||||
size="xl"
|
||||
centered
|
||||
>
|
||||
<Stack spacing="md">
|
||||
{series.description && (
|
||||
<Text size="sm" color="dimmed">
|
||||
{series.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group spacing="md">
|
||||
{series.year && <Badge color="blue">{series.year}</Badge>}
|
||||
{series.rating && <Badge color="yellow">{series.rating}</Badge>}
|
||||
{series.genre && <Badge color="gray">{series.genre}</Badge>}
|
||||
</Group>
|
||||
|
||||
<Title order={4}>Episodes</Title>
|
||||
|
||||
{loading ? (
|
||||
<Flex justify="center" py="xl">
|
||||
<Loader />
|
||||
</Flex>
|
||||
) : (
|
||||
<Grid>
|
||||
{episodes.map(episode => (
|
||||
<Grid.Col span={6} key={episode.id}>
|
||||
<VODCard vod={episode} onClick={handlePlayEpisode} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const VODsPage = () => {
|
||||
const {
|
||||
vods,
|
||||
series,
|
||||
categories,
|
||||
loading,
|
||||
filters,
|
||||
currentPage,
|
||||
totalCount,
|
||||
pageSize,
|
||||
setFilters,
|
||||
setPage,
|
||||
fetchVODs,
|
||||
fetchSeries,
|
||||
fetchCategories
|
||||
} = useVODStore();
|
||||
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const [selectedSeries, setSelectedSeries] = useState(null);
|
||||
const [seriesModalOpened, { open: openSeriesModal, close: closeSeriesModal }] = useDisclosure(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, [fetchCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filters.type === 'series') {
|
||||
fetchSeries();
|
||||
} else {
|
||||
fetchVODs();
|
||||
}
|
||||
}, [filters, currentPage, fetchVODs, fetchSeries]);
|
||||
|
||||
const handlePlayVOD = (vod) => {
|
||||
const streamUrl = `${window.location.origin}${vod.stream_url}`;
|
||||
showVideo(streamUrl);
|
||||
};
|
||||
|
||||
const handleSeriesClick = (series) => {
|
||||
setSelectedSeries(series);
|
||||
openSeriesModal();
|
||||
};
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: '', label: 'All Categories' },
|
||||
...Object.values(categories).map(cat => ({
|
||||
value: cat.name,
|
||||
label: cat.name
|
||||
}))
|
||||
];
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Stack spacing="md">
|
||||
<Group position="apart">
|
||||
<Title order={2}>Video on Demand</Title>
|
||||
</Group>
|
||||
|
||||
{/* Filters */}
|
||||
<Group spacing="md">
|
||||
<SegmentedControl
|
||||
value={filters.type}
|
||||
onChange={(value) => setFilters({ type: value })}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Movies', value: 'movies' },
|
||||
{ label: 'Series', value: 'series' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search VODs..."
|
||||
icon={<Search size={16} />}
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ search: e.target.value })}
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="Category"
|
||||
data={categoryOptions}
|
||||
value={filters.category}
|
||||
onChange={(value) => setFilters({ category: value })}
|
||||
clearable
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<Flex justify="center" py="xl">
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
{filters.type === 'series' ? (
|
||||
<Grid>
|
||||
{Object.values(series).map(seriesItem => (
|
||||
<Grid.Col span={3} key={seriesItem.id}>
|
||||
<SeriesCard
|
||||
series={seriesItem}
|
||||
onClick={handleSeriesClick}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid>
|
||||
{Object.values(vods).map(vod => (
|
||||
<Grid.Col span={3} key={vod.id}>
|
||||
<VODCard vod={vod} onClick={handlePlayVOD} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Flex justify="center" mt="md">
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
onChange={setPage}
|
||||
total={totalPages}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Series Episodes Modal */}
|
||||
<SeriesModal
|
||||
series={selectedSeries}
|
||||
opened={seriesModalOpened}
|
||||
onClose={closeSeriesModal}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VODsPage;
|
||||
131
frontend/src/store/useVODStore.jsx
Normal file
131
frontend/src/store/useVODStore.jsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { create } from 'zustand';
|
||||
import API from '../api';
|
||||
|
||||
const host = window.location.origin;
|
||||
|
||||
const useVODStore = create((set, get) => ({
|
||||
// State
|
||||
vods: {},
|
||||
series: {},
|
||||
categories: {},
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// Filters and pagination
|
||||
currentPage: 1,
|
||||
pageSize: 50,
|
||||
totalCount: 0,
|
||||
filters: {
|
||||
type: 'all', // 'all', 'movies', 'series'
|
||||
category: '',
|
||||
search: '',
|
||||
year: null,
|
||||
seriesId: null
|
||||
},
|
||||
|
||||
// Actions
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
setFilters: (newFilters) => set((state) => ({
|
||||
filters: { ...state.filters, ...newFilters },
|
||||
currentPage: 1 // Reset to first page when filters change
|
||||
})),
|
||||
|
||||
setPage: (page) => set({ currentPage: page }),
|
||||
|
||||
fetchVODs: async () => {
|
||||
const { filters, currentPage, pageSize } = get();
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
...Object.fromEntries(
|
||||
Object.entries(filters).filter(([_, value]) =>
|
||||
value !== null && value !== '' && value !== 'all'
|
||||
)
|
||||
)
|
||||
});
|
||||
|
||||
const response = await API.request(`${host}/api/vod/vods/?${params}`);
|
||||
|
||||
set({
|
||||
vods: response.results || response,
|
||||
totalCount: response.count || response.length || 0,
|
||||
loading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSeries: async () => {
|
||||
const { filters, currentPage, pageSize } = get();
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
search: filters.search || ''
|
||||
});
|
||||
|
||||
const response = await API.request(`${host}/api/vod/series/?${params}`);
|
||||
|
||||
set({
|
||||
series: response.results || response,
|
||||
totalCount: response.count || response.length || 0,
|
||||
loading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchCategories: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await API.request(`${host}/api/vod/categories/`);
|
||||
set({
|
||||
categories: response.results || response,
|
||||
loading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSeriesEpisodes: async (seriesId) => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const response = await API.request(`${host}/api/vod/series/${seriesId}/episodes/`);
|
||||
set({
|
||||
vods: response.results || response,
|
||||
totalCount: response.count || response.length || 0,
|
||||
loading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: error.message, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Clear data
|
||||
clearVODs: () => set({ vods: {}, totalCount: 0 }),
|
||||
clearSeries: () => set({ series: {} }),
|
||||
clearFilters: () => set({
|
||||
filters: {
|
||||
type: 'all',
|
||||
category: '',
|
||||
search: '',
|
||||
year: null,
|
||||
seriesId: null
|
||||
},
|
||||
currentPage: 1
|
||||
})
|
||||
}));
|
||||
|
||||
export default useVODStore;
|
||||
Loading…
Add table
Add a link
Reference in a new issue