diff --git a/api.py b/api.py index 40ce757f5a8d8b7b4a92451bcc50cd7a8bd35e2e..b380dff26560daed665aed28171e57d4d0fc11a4 100644 --- a/api.py +++ b/api.py @@ -18,7 +18,7 @@ from picview.database import ( UserData, PictureData, AlbumData, - Share, + SharedAlbumData, ) from picview.utils import create_access_token @@ -210,17 +210,17 @@ async def get_picture_file( return {} -@app.get("/shares", response_model=list[Share], tags=["shares"]) +@app.get("/shares", response_model=list[SharedAlbumData], tags=["shares"]) async def get_shares( current_user: Annotated[User, Depends(get_current_user)], -) -> list[Share]: +) -> list[SharedAlbumData]: database = Database(config) return database.get_shares(current_user) -@app.get("/shares/{secret}", response_model=Share | None, tags=["shares"]) -async def get_share(secret: str) -> Share | None: +@app.get("/shares/{secret}", response_model=SharedAlbumData | None, tags=["shares"]) +async def get_share(secret: str) -> SharedAlbumData | None: database = Database(config) return database.get_share(secret) diff --git a/picview/database.py b/picview/database.py index 8fce3795b88923ca063f8e13d158f7d51c753577..a3c7897f70a7dc22b114a2ddbb6909cf8d72c394 100644 --- a/picview/database.py +++ b/picview/database.py @@ -58,6 +58,17 @@ class AlbumData(BaseModel): tags: list[TagData] +class SharedAlbumData(BaseModel): + id: int | None + name: str + desc: str + begin: datetime + end: datetime + tags: list[TagData] + expiration_date: datetime + secret: str + + class UserData(BaseModel): login: str @@ -199,17 +210,42 @@ class Database: ) return self.session.exec(query).first() - def get_shares(self, user: User) -> list[Share]: - - query = select(Share).join(Album).where(Album.user == user.id) - - shares = self.session.exec(query).all() - return list(shares) - - def get_share(self, secret: str) -> Share | None: - query = select(Share).join(Album).where(Share.secret == secret) - share = self.session.exec(query).first() - return share + def get_shares(self, user: User) -> list[SharedAlbumData]: + + query = select(Share, Album).join(Album).where(Album.user == user.id) + + shares_albums = self.session.exec(query).all() + result = [] + for share, album in shares_albums: + result.append( + SharedAlbumData( + id=album.id, + name=album.name, + desc=album.desc, + begin=album.begin, + end=album.end, + tags=[TagData(label=t.label, color=t.color) for t in album.tags], + expiration_date=share.expiration_date, + secret=share.secret, + ) + ) + return result + + def get_share(self, secret: str) -> SharedAlbumData | None: + query = select(Share, Album).join(Album).where(Share.secret == secret) + share_album = self.session.exec(query).first() + if share_album: + share, album = share_album + return SharedAlbumData( + id=album.id, + name=album.name, + desc=album.desc, + begin=album.begin, + end=album.end, + tags=[TagData(label=t.label, color=t.color) for t in album.tags], + expiration_date=share.expiration_date, + secret=share.secret, + ) def get_share_pictures(self, share_secret: str) -> list[Picture]: diff --git a/src/App.jsx b/src/App.jsx index c04555808d6934fb6c8e67521d9f449c33109700..c04fe10e8ca4db0ef0fd886b28a3f00417f4e3ce 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -19,6 +19,7 @@ import Album from "./routes/Album"; import Albums from "./routes/Albums"; import Login from "./routes/Login"; import Main from "./routes/Main"; +import Share from "./routes/Share"; // components import NavBar from "./components/Navbar"; @@ -41,6 +42,7 @@ export default function App() { <NavBar /> <Routes> <Route path="/login" element={<Login />} /> + <Route path="/shares/:shareId" element={<Share />} /> <Route element={<RequireAuth />}> <Route path="/" element={<Main />} /> <Route path="/albums" element={<Albums />} /> diff --git a/src/components/Album.jsx b/src/components/Album.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3273886dc5900930987d5f9c5d6fd3e44298dbbb --- /dev/null +++ b/src/components/Album.jsx @@ -0,0 +1,93 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { useEffect, useState } from "react"; + +import FullScreenPicture from "../components/FullScreenPicture"; +import PictureList from "../components/PictureList"; +import { blobUrlMapping } from "../components/Thumbnail"; +import { getScreenSize } from "../functions/utils"; + +export default function Album({ album, pictures }) { + // screen size + const [screenSize, setScreenSize] = useState(getScreenSize()); + const handleResize = () => { + setScreenSize(getScreenSize()); + }; + useEffect(() => { + window.addEventListener("resize", handleResize); + }); + + const [fullPicture, setFullPicture] = useState([null, null]); + const [openFullScreen, setOpenFullScreen] = useState(false); + const findPicture = (picId, nextOrPrev) => { + const npi = pictures.findIndex((p) => p.id === picId); + return pictures[npi + (nextOrPrev === "next" ? 1 : -1)]; + }; + + const nextFullScreenPicture = (picId) => { + const nextPic = findPicture(picId, "next"); + if (nextPic) { + setFullPicture([nextPic, blobUrlMapping[nextPic.id]]); + } else { + setFullPicture([null, null]); + setOpenFullScreen(false); + } + }; + const prevFullScreenPicture = (picId) => { + const prevPic = findPicture(picId, "prev"); + if (prevPic) { + setFullPicture([prevPic, blobUrlMapping[prevPic.id]]); + } else { + setFullPicture([null, null]); + setOpenFullScreen(false); + } + }; + const thumbnailObject = {}; + pictures.forEach((picture) => { + const cdate = new Date(picture.cdate); + const strDate = `${cdate.getFullYear()}-${String( + cdate.getMonth() + 1, + ).padStart(2, "0")}-${String(cdate.getDate()).padStart(2, "0")}`; + if (thumbnailObject[strDate] === undefined) { + thumbnailObject[strDate] = []; + } + thumbnailObject[strDate].push(picture); + }); + const listOfPictureList = []; + Object.entries(thumbnailObject).forEach(([date, pictures]) => { + listOfPictureList.push( + <PictureList + dateStr={date} + pictures={pictures} + setOpenFullScreen={setOpenFullScreen} + setFullPicture={setFullPicture} + />, + ); + }); + const title = album ? ( + <div> + <Typography variant="h3">{album.name}</Typography> + <Typography variant="h6" gutterBottom> + {album.desc} + </Typography> + <hr /> + </div> + ) : null; + + return ( + <> + <Box component="section" sx={{ p: screenSize === "small" ? 0 : 2 }}> + <FullScreenPicture + open={openFullScreen} + setOpen={setOpenFullScreen} + picture={fullPicture} + setPicture={setFullPicture} + nextFullScreenPicture={nextFullScreenPicture} + prevFullScreenPicture={prevFullScreenPicture} + /> + {title} + {listOfPictureList} + </Box> + </> + ); +} diff --git a/src/components/FullScreenPicture.jsx b/src/components/FullScreenPicture.jsx index b03db9f9f1b87356ef8925a29d93723e1e20681a..4d71f4c23b88c3c5d0a79a69575bee3205ee239c 100644 --- a/src/components/FullScreenPicture.jsx +++ b/src/components/FullScreenPicture.jsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import ReactTouchEvents from "react-touch-events"; import Dialog from "@mui/material/Dialog"; -import { fetchPicture } from "../functions/api"; +import { fetchPicture, fetchSharePicture } from "../functions/api"; +import { AlbumContext } from "../context/AlbumTypeContext"; import { useUser } from "../context/UserContext"; export default function FullScreenPicture({ @@ -18,6 +19,8 @@ export default function FullScreenPicture({ const { token } = useUser(); const [hugeBlobUrl, setHugeBlobUrl] = useState([null, null]); + const albumContext = useContext(AlbumContext); + useEffect(() => { if (picture[1]) { setHugeBlobUrl([picture[1], false]); @@ -53,12 +56,15 @@ export default function FullScreenPicture({ const controller = new AbortController(); if (picture[0]) { const fetchData = async () => { - const responseData = await fetchPicture( - token, - picture[0].id, - false, - controller, - ); + const responseData = + albumContext.type === "share" + ? await fetchSharePicture( + albumContext.secret, + picture[0].id, + false, + controller, + ) + : await fetchPicture(token, picture[0].id, false, controller); if (responseData) { setHugeBlobUrl([URL.createObjectURL(responseData), true]); } diff --git a/src/components/Thumbnail.jsx b/src/components/Thumbnail.jsx index 3404674d702101d4402b1e295726f2fae1435987..e79803b13cf0d76d9def7d9f75e13ea9e298181e 100644 --- a/src/components/Thumbnail.jsx +++ b/src/components/Thumbnail.jsx @@ -4,14 +4,15 @@ import Card from "@mui/material/Card"; import IconButton from "@mui/material/IconButton"; import ImageListItem from "@mui/material/ImageListItem"; import ImageListItemBar from "@mui/material/ImageListItemBar"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import PictureInfoModal from "./PictureInfoModal"; import { getScreenSize } from "../functions/utils"; -import { fetchPicture } from "../functions/api"; +import { fetchPicture, fetchSharePicture } from "../functions/api"; +import { AlbumContext } from "../context/AlbumTypeContext"; import { useUser } from "../context/UserContext"; export let instancesCount = 0; @@ -28,6 +29,8 @@ export default function Thumbnail({ const [opacity, setOpacity] = useState(0); const [openModal, setOpenModal] = useState(false); + const albumContext = useContext(AlbumContext); + const [screenSize, setScreenSize] = useState(getScreenSize()); const handleResize = () => { setScreenSize(getScreenSize()); @@ -45,7 +48,10 @@ export default function Thumbnail({ useEffect(() => { const fetchData = async () => { - const responseData = await fetchPicture(token, picture.id, true); + const responseData = + albumContext.type === "share" + ? await fetchSharePicture(albumContext.secret, picture.id, true) + : await fetchPicture(token, picture.id, true); setBlobUrl(URL.createObjectURL(responseData)); blobUrlMapping[picture.id] = URL.createObjectURL(responseData); setLoading(false); diff --git a/src/context/AlbumTypeContext.js b/src/context/AlbumTypeContext.js new file mode 100644 index 0000000000000000000000000000000000000000..0baaada5941b0248ba54af9904eaafaa7aacb484 --- /dev/null +++ b/src/context/AlbumTypeContext.js @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const AlbumContext = createContext({ type: "album", secret: null }); diff --git a/src/functions/api.js b/src/functions/api.js index f3f3e8ecbc9f7b4a99d272490b21258e7b994fd1..6349e79b101076b2eb95596fe357ddbf1fdfe9e4 100644 --- a/src/functions/api.js +++ b/src/functions/api.js @@ -61,13 +61,21 @@ export const fetchAlbums = async (token) => { }; export const fetchAlbum = async (token, albumId) => { - return await apiRequest(`/album/${albumId}`, getHeaders(token)); + return await apiRequest(`/albums/${albumId}`, getHeaders(token)); }; export const fetchAlbumPictures = async (token, albumId) => { return await apiRequest(`/albums/pictures/${albumId}`, getHeaders(token)); }; +export const fetchShare = async (shareId) => { + return await apiRequest(`/shares/${shareId}`); +}; + +export const fetchSharePictures = async (shareId) => { + return await apiRequest(`/shares/pictures/${shareId}`); +}; + export const fetchPicture = async ( token, pictureId, @@ -88,6 +96,26 @@ export const fetchPicture = async ( ); }; +export const fetchSharePicture = async ( + shareId, + pictureId, + thumbnail = false, + controller = null, +) => { + return await apiRequest( + `/shares/pictures/file/${shareId}/${pictureId}?thumbnail=${thumbnail}`, + getHeaders(), + "GET", + null, + { + accept: "*/*", + responseType: "blob", + signal: controller ? controller.signal : null, + }, + true, + ); +}; + export const fetchToken = async (login, password) => { const headers = { "Content-Type": "application/x-www-form-urlencoded" }; const dataStr = `grant_type=password&username=${login}&password=${password}&scope=&client_id=string&client_secret=string`; diff --git a/src/routes/Album.jsx b/src/routes/Album.jsx index 87f2dd59cc8588f56133feb30b14463ab9b7293e..75d3e3fe6fa8ccf757ad7c3a320a32ac3d19c8e3 100644 --- a/src/routes/Album.jsx +++ b/src/routes/Album.jsx @@ -1,15 +1,11 @@ import { useEffect, useState } from "react"; import { useParams } from "react-router"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; +import { default as AlbumComponent } from "../components/Album"; -import FullScreenPicture from "../components/FullScreenPicture"; -import PictureList from "../components/PictureList"; -import { blobUrlMapping } from "../components/Thumbnail"; import { fetchAlbum, fetchAlbumPictures } from "../functions/api"; -import { getScreenSize } from "../functions/utils"; +import { AlbumContext } from "../context/AlbumTypeContext"; import { useUser } from "../context/UserContext"; export default function Album() { @@ -19,40 +15,6 @@ export default function Album() { const { token } = useUser(); - // screen size - const [screenSize, setScreenSize] = useState(getScreenSize()); - const handleResize = () => { - setScreenSize(getScreenSize()); - }; - useEffect(() => { - window.addEventListener("resize", handleResize); - }); - - const [fullPicture, setFullPicture] = useState([null, null]); - const [openFullScreen, setOpenFullScreen] = useState(false); - const findPicture = (picId, nextOrPrev) => { - const npi = pictures.findIndex((p) => p.id === picId); - return pictures[npi + (nextOrPrev === "next" ? 1 : -1)]; - }; - const nextFullScreenPicture = (picId) => { - const nextPic = findPicture(picId, "next"); - if (nextPic) { - setFullPicture([nextPic, blobUrlMapping[nextPic.id]]); - } else { - setFullPicture([null, null]); - setOpenFullScreen(false); - } - }; - const prevFullScreenPicture = (picId) => { - const prevPic = findPicture(picId, "prev"); - if (prevPic) { - setFullPicture([prevPic, blobUrlMapping[prevPic.id]]); - } else { - setFullPicture([null, null]); - setOpenFullScreen(false); - } - }; - useEffect(() => { const fetchData = async () => { const responseData = await fetchAlbum(token, albumId); @@ -69,52 +31,9 @@ export default function Album() { fetchData(); }, [album]); - const thumbnailObject = {}; - pictures.forEach((picture) => { - const cdate = new Date(picture.cdate); - const strDate = `${cdate.getFullYear()}-${String( - cdate.getMonth() + 1, - ).padStart(2, "0")}-${String(cdate.getDate()).padStart(2, "0")}`; - if (thumbnailObject[strDate] === undefined) { - thumbnailObject[strDate] = []; - } - thumbnailObject[strDate].push(picture); - }); - const listOfPictureList = []; - Object.entries(thumbnailObject).forEach(([date, pictures]) => { - listOfPictureList.push( - <PictureList - dateStr={date} - pictures={pictures} - setOpenFullScreen={setOpenFullScreen} - setFullPicture={setFullPicture} - />, - ); - }); - const title = album ? ( - <div> - <Typography variant="h3">{album.name}</Typography> - <Typography variant="h6" gutterBottom> - {album.desc} - </Typography> - <hr /> - </div> - ) : null; - return ( - <> - <Box component="section" sx={{ p: screenSize === "small" ? 0 : 2 }}> - <FullScreenPicture - open={openFullScreen} - setOpen={setOpenFullScreen} - picture={fullPicture} - setPicture={setFullPicture} - nextFullScreenPicture={nextFullScreenPicture} - prevFullScreenPicture={prevFullScreenPicture} - /> - {title} - {listOfPictureList} - </Box> - </> + <AlbumContext.Provider value={{ type: "album" }}> + <AlbumComponent album={album} pictures={pictures} /> + </AlbumContext.Provider> ); } diff --git a/src/routes/Share.jsx b/src/routes/Share.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d83d571c24dd49dc2e5ae0bd000098c4936842c9 --- /dev/null +++ b/src/routes/Share.jsx @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router"; + +import { default as AlbumComponent } from "../components/Album"; + +import { fetchShare, fetchSharePictures } from "../functions/api"; + +import { AlbumContext } from "../context/AlbumTypeContext"; + +export default function Share() { + const { shareId } = useParams(); + const [share, setShare] = useState(null); + const [pictures, setPictures] = useState([]); + + useEffect(() => { + const fetchData = async () => { + const responseData = await fetchShare(shareId); + setShare(responseData); + }; + fetchData(); + }, []); + + useEffect(() => { + const fetchData = async () => { + const responseData = await fetchSharePictures(shareId); + setPictures(responseData); + }; + fetchData(); + }, [share]); + + return ( + <AlbumContext.Provider value={{ type: "share", secret: shareId }}> + <AlbumComponent album={share} pictures={pictures} /> + </AlbumContext.Provider> + ); +}