diff --git a/backend/internal/models/album.go b/backend/internal/models/album.go index 1449f92..bffcc17 100644 --- a/backend/internal/models/album.go +++ b/backend/internal/models/album.go @@ -14,8 +14,8 @@ type Album struct { Private bool `gorm:"not null"` // Public albums have a default access level of 0 for all visitors, including guests. // Private albums require a user with access to be logged in to view, or a magic link to be used. - ParentID string `gorm:"not null"` // The ID of the parent album, if any. This is an empty string for root albums. - Thumbnail string `gorm:"not null"` // The media ID of the thumbnail for the album. + ParentID string `gorm:"not null"` // The ID of the parent album, if any. This is an empty string for root albums. + Thumbnail string `gorm:"not null;default:''"` // The media ID of the thumbnail for the album. CreatedAt time.Time UpdatedAt time.Time } diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index b707f1a..e1e3dff 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -4,7 +4,7 @@ import "time" type User struct { ID string `gorm:"primaryKey"` - Username string `gorm:"not null"` + Username string `gorm:"not null unique"` Password string `gorm:"not null"` IsAdmin bool `gorm:"not null"` IsRoot bool `gorm:"not null"` diff --git a/backend/internal/routes/album.go b/backend/internal/routes/album.go index 4374b29..f560f4b 100644 --- a/backend/internal/routes/album.go +++ b/backend/internal/routes/album.go @@ -51,4 +51,19 @@ func RegisterAlbumRoutes(rg *gin.RouterGroup) { } c.JSON(http.StatusOK, result) }) + album.POST("/getIDFromPath", func(c *gin.Context) { + var request struct { + Path string `json:"path"` + } + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id, err := services.GetIDFromPath(request.Path) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"id": id}) + }) } diff --git a/backend/internal/services/album.go b/backend/internal/services/album.go index 2cbf016..fca1c47 100644 --- a/backend/internal/services/album.go +++ b/backend/internal/services/album.go @@ -2,6 +2,8 @@ package services import ( "fmt" + "regexp" + "strings" "github.com/google/uuid" "github.com/wisplite/raster/internal/db" @@ -67,19 +69,31 @@ func CreateAlbum(accessToken string, title string, description string, parentID if accessLevel < 2 { return models.Album{}, fmt.Errorf("user does not have permission to create albums in this parent") } + if !regexp.MustCompile(`^[a-zA-Z0-9\s\-_]+$`).MatchString(title) { + return models.Album{}, fmt.Errorf("title can only contain alphanumeric characters, spaces, and hyphens/underscores") + } + // check for duplicate title in parent + existingAlbum := models.Album{} + result := db.GetDB().First(&existingAlbum, "title = ? AND parent_id = ?", title, parentID) + if result.Error != nil && result.Error != gorm.ErrRecordNotFound { + return models.Album{}, result.Error + } + if existingAlbum.ID != "" { + return models.Album{}, fmt.Errorf("album with this title already exists in this parent") + } albumID := uuid.New().String() - album := models.Album{ + newAlbum := models.Album{ ID: albumID, Title: title, Description: description, ParentID: parentID, Thumbnail: "", } - result := db.GetDB().Create(&album) - if result.Error != nil { + newAlbumResult := db.GetDB().Create(&newAlbum) + if newAlbumResult.Error != nil { return models.Album{}, result.Error } - return album, nil + return newAlbum, nil } func CheckUserAlbumAccess(userID string, albumID string) (int, error) { @@ -100,3 +114,23 @@ func CheckUserAlbumAccess(userID string, albumID string) (int, error) { } return userAccess.AccessLevel, nil } + +func GetIDFromPath(path string) (string, error) { + currentParentID := "" + segments := strings.Split(path, "/") + + for _, segment := range segments { + if segment == "" { + continue + } + + var album models.Album + result := db.GetDB().Where("title = ? AND parent_id = ?", segment, currentParentID).First(&album) + if result.Error != nil { + return "", result.Error + } + currentParentID = album.ID + } + + return currentParentID, nil +} diff --git a/frontend/src/account/createRoot.jsx b/frontend/src/account/createRoot.jsx index 48ff233..b4890cc 100644 --- a/frontend/src/account/createRoot.jsx +++ b/frontend/src/account/createRoot.jsx @@ -1,9 +1,11 @@ import { useState } from 'react' import { getServerUrl } from '../hooks/getConstants' import { useNavigate } from 'react-router-dom' +import { useNotifier } from '../contexts/useNotifier' export default function CreateRootUser() { const navigate = useNavigate() + const { showError, showSuccess } = useNotifier() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const handleCreateRootUser = async () => { @@ -19,7 +21,7 @@ export default function CreateRootUser() { }) const data = await response.json() if (data.error) { - console.error(data.error) + showError(data.error) } else { const rootResponse = await fetch(`${getServerUrl()}/api/user/setRootUser`, { method: 'POST', @@ -32,9 +34,10 @@ export default function CreateRootUser() { }) const rootData = await rootResponse.json() if (rootData.error) { - console.error(rootData.error) + showError(rootData.error) } else { navigate('/gallery') + showSuccess('Root user created successfully') } } } diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx index 3078ab0..7da4522 100644 --- a/frontend/src/components/NavBar.jsx +++ b/frontend/src/components/NavBar.jsx @@ -12,7 +12,7 @@ export default function NavBar({ path }) { {path.map((item, index) => (
- {item} + {decodeURIComponent(item)} {index !== path.length - 1 &&

/

}
diff --git a/frontend/src/gallery/components/AlbumCreateModal.jsx b/frontend/src/gallery/components/AlbumCreateModal.jsx index 8288e80..05a1d10 100644 --- a/frontend/src/gallery/components/AlbumCreateModal.jsx +++ b/frontend/src/gallery/components/AlbumCreateModal.jsx @@ -2,10 +2,12 @@ import Modal from '../../components/Modal' import { getServerUrl } from '../../hooks/getConstants' import { useAccount } from '../../contexts/useAccount' import { useState } from 'react' +import { useNotifier } from '../../contexts/useNotifier' export default function AlbumCreateModal({ open, onOpenChange, trigger, parentId }) { const { getAccessToken } = useAccount() const [title, setTitle] = useState('') const [description, setDescription] = useState('') + const { showError } = useNotifier() const handleCreateAlbum = async () => { const response = await fetch(`${getServerUrl()}/api/albums/createAlbum`, { method: 'POST', @@ -21,7 +23,7 @@ export default function AlbumCreateModal({ open, onOpenChange, trigger, parentId }) const data = await response.json() if (data.error) { - console.error(data.error) + showError(data.error) } else { onOpenChange(false) } diff --git a/frontend/src/gallery/components/AlbumList.jsx b/frontend/src/gallery/components/AlbumList.jsx index fd29a35..a636c17 100644 --- a/frontend/src/gallery/components/AlbumList.jsx +++ b/frontend/src/gallery/components/AlbumList.jsx @@ -3,10 +3,14 @@ import AlbumCreateModal from './AlbumCreateModal' import { useState, useEffect } from 'react' import { getServerUrl } from '../../hooks/getConstants' import { useAccount } from '../../contexts/useAccount' +import { useNavigate } from 'react-router-dom' +import { useNotifier } from '../../contexts/useNotifier' export default function AlbumList({ currentAlbumName }) { const { getAccessToken } = useAccount() const [open, setOpen] = useState(false) const [albums, setAlbums] = useState([]) + const navigate = useNavigate() + const { showError } = useNotifier() const getAlbums = async () => { console.log('Getting albums in parent', currentAlbumName) if (currentAlbumName === 'gallery') { // Root album @@ -20,10 +24,29 @@ export default function AlbumList({ currentAlbumName }) { }), }) const data = await response.json() - console.log('Albums', data) - setAlbums(data) + if (data.error) { + setAlbums([]) + showError('Failed to get albums') + } else { + setAlbums(data) + } } else { - setAlbums([]) + const response = await fetch(`${getServerUrl()}/api/albums/getAlbumsInParent`, { + method: 'POST', + headers: { + 'Authorization': getAccessToken(), + }, + body: JSON.stringify({ + parentId: currentAlbumName, + }), + }) + const data = await response.json() + if (data.error) { + setAlbums([]) + showError('Failed to get albums') + } else { + setAlbums(data) + } } } useEffect(() => { @@ -39,12 +62,26 @@ export default function AlbumList({ currentAlbumName }) {
{albums.map((album) => ( -
+
{ + // Get current path and append the album's title + const currentPath = window.location.pathname; + // Remove leading and trailing slashes, split to parts + const pathParts = currentPath.replace(/^\/|\/$/g, '').split('/'); + // Only append if not already the last part (avoid duplicate navigation) + if (pathParts[pathParts.length - 1] !== album.Title) { + navigate(`${currentPath.replace(/\/$/, '')}/${encodeURIComponent(album.Title)}`); + } else { + navigate(currentPath); // Or optionally do nothing/navigate to self + } + }} + >

{album.Title}

))}
- +
) } \ No newline at end of file diff --git a/frontend/src/gallery/index.jsx b/frontend/src/gallery/index.jsx index 277acd0..1e0835c 100644 --- a/frontend/src/gallery/index.jsx +++ b/frontend/src/gallery/index.jsx @@ -1,22 +1,58 @@ import NavBar from '../components/NavBar' import { useLocation } from 'react-router-dom'; import { useAccount } from '../contexts/useAccount'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import AlbumList from './components/AlbumList'; +import { getServerUrl } from '../hooks/getConstants'; +import { useNotifier } from '../contexts/useNotifier'; export default function Gallery() { const currentPath = useLocation().pathname; const pathList = currentPath.split('/').slice(1); const currentAlbumName = pathList[pathList.length - 1]; + const [currentAlbumID, setCurrentAlbumID] = useState("!notfound!"); // set to impossible value to prevent client from fetching root album const { fetchUserData, user } = useAccount() - + const { getAccessToken } = useAccount() + const { showError } = useNotifier() useEffect(() => { fetchUserData() }, []) + useEffect(() => { + const getCurrentAlbumID = async () => { + console.log("currentAlbumName", currentAlbumName) + console.log("pathList", pathList) + if (currentAlbumName === 'gallery') { + setCurrentAlbumID(''); + return; + } + const response = await fetch(`${getServerUrl()}/api/albums/getIDFromPath`, { + method: 'POST', + headers: { + 'Authorization': getAccessToken(), + }, + body: JSON.stringify({ + path: decodeURIComponent(pathList.slice(1).join('/')), + }), + }) + const data = await response.json() + if (data.error) { + setCurrentAlbumID("!notfound!") + showError('Album not found') + } else { + setCurrentAlbumID(data.id); + } + }; + getCurrentAlbumID(); + }, [currentPath]); + + useEffect(() => { + console.log("currentAlbumID", currentAlbumID) + }, [currentAlbumID]) + return (
- +
) } \ No newline at end of file