working album navigation and duplicate checks on backend

This commit is contained in:
wisplite
2025-11-22 23:18:53 -06:00
parent 8035be9a60
commit d9bad97a53
9 changed files with 146 additions and 19 deletions
+2 -2
View File
@@ -14,8 +14,8 @@ type Album struct {
Private bool `gorm:"not null"` Private bool `gorm:"not null"`
// Public albums have a default access level of 0 for all visitors, including guests. // 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. // 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. 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. Thumbnail string `gorm:"not null;default:''"` // The media ID of the thumbnail for the album.
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import "time"
type User struct { type User struct {
ID string `gorm:"primaryKey"` ID string `gorm:"primaryKey"`
Username string `gorm:"not null"` Username string `gorm:"not null unique"`
Password string `gorm:"not null"` Password string `gorm:"not null"`
IsAdmin bool `gorm:"not null"` IsAdmin bool `gorm:"not null"`
IsRoot bool `gorm:"not null"` IsRoot bool `gorm:"not null"`
+15
View File
@@ -51,4 +51,19 @@ func RegisterAlbumRoutes(rg *gin.RouterGroup) {
} }
c.JSON(http.StatusOK, result) 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})
})
} }
+38 -4
View File
@@ -2,6 +2,8 @@ package services
import ( import (
"fmt" "fmt"
"regexp"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/wisplite/raster/internal/db" "github.com/wisplite/raster/internal/db"
@@ -67,19 +69,31 @@ func CreateAlbum(accessToken string, title string, description string, parentID
if accessLevel < 2 { if accessLevel < 2 {
return models.Album{}, fmt.Errorf("user does not have permission to create albums in this parent") 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() albumID := uuid.New().String()
album := models.Album{ newAlbum := models.Album{
ID: albumID, ID: albumID,
Title: title, Title: title,
Description: description, Description: description,
ParentID: parentID, ParentID: parentID,
Thumbnail: "", Thumbnail: "",
} }
result := db.GetDB().Create(&album) newAlbumResult := db.GetDB().Create(&newAlbum)
if result.Error != nil { if newAlbumResult.Error != nil {
return models.Album{}, result.Error return models.Album{}, result.Error
} }
return album, nil return newAlbum, nil
} }
func CheckUserAlbumAccess(userID string, albumID string) (int, error) { func CheckUserAlbumAccess(userID string, albumID string) (int, error) {
@@ -100,3 +114,23 @@ func CheckUserAlbumAccess(userID string, albumID string) (int, error) {
} }
return userAccess.AccessLevel, nil 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
}
+5 -2
View File
@@ -1,9 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { getServerUrl } from '../hooks/getConstants' import { getServerUrl } from '../hooks/getConstants'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useNotifier } from '../contexts/useNotifier'
export default function CreateRootUser() { export default function CreateRootUser() {
const navigate = useNavigate() const navigate = useNavigate()
const { showError, showSuccess } = useNotifier()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const handleCreateRootUser = async () => { const handleCreateRootUser = async () => {
@@ -19,7 +21,7 @@ export default function CreateRootUser() {
}) })
const data = await response.json() const data = await response.json()
if (data.error) { if (data.error) {
console.error(data.error) showError(data.error)
} else { } else {
const rootResponse = await fetch(`${getServerUrl()}/api/user/setRootUser`, { const rootResponse = await fetch(`${getServerUrl()}/api/user/setRootUser`, {
method: 'POST', method: 'POST',
@@ -32,9 +34,10 @@ export default function CreateRootUser() {
}) })
const rootData = await rootResponse.json() const rootData = await rootResponse.json()
if (rootData.error) { if (rootData.error) {
console.error(rootData.error) showError(rootData.error)
} else { } else {
navigate('/gallery') navigate('/gallery')
showSuccess('Root user created successfully')
} }
} }
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export default function NavBar({ path }) {
{path.map((item, index) => ( {path.map((item, index) => (
<div className="flex flex-row items-center justify-start gap-2 red-hat-mono"> <div className="flex flex-row items-center justify-start gap-2 red-hat-mono">
<Link to={`/${path.slice(0, index + 1).join('/')}`} key={item} className={`text-white ${index === path.length - 1 ? 'font-bold' : ''}`}> <Link to={`/${path.slice(0, index + 1).join('/')}`} key={item} className={`text-white ${index === path.length - 1 ? 'font-bold' : ''}`}>
{item} {decodeURIComponent(item)}
</Link> </Link>
{index !== path.length - 1 && <p className="text-white red-hat-mono">/</p>} {index !== path.length - 1 && <p className="text-white red-hat-mono">/</p>}
</div> </div>
@@ -2,10 +2,12 @@ import Modal from '../../components/Modal'
import { getServerUrl } from '../../hooks/getConstants' import { getServerUrl } from '../../hooks/getConstants'
import { useAccount } from '../../contexts/useAccount' import { useAccount } from '../../contexts/useAccount'
import { useState } from 'react' import { useState } from 'react'
import { useNotifier } from '../../contexts/useNotifier'
export default function AlbumCreateModal({ open, onOpenChange, trigger, parentId }) { export default function AlbumCreateModal({ open, onOpenChange, trigger, parentId }) {
const { getAccessToken } = useAccount() const { getAccessToken } = useAccount()
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const { showError } = useNotifier()
const handleCreateAlbum = async () => { const handleCreateAlbum = async () => {
const response = await fetch(`${getServerUrl()}/api/albums/createAlbum`, { const response = await fetch(`${getServerUrl()}/api/albums/createAlbum`, {
method: 'POST', method: 'POST',
@@ -21,7 +23,7 @@ export default function AlbumCreateModal({ open, onOpenChange, trigger, parentId
}) })
const data = await response.json() const data = await response.json()
if (data.error) { if (data.error) {
console.error(data.error) showError(data.error)
} else { } else {
onOpenChange(false) onOpenChange(false)
} }
+42 -5
View File
@@ -3,10 +3,14 @@ import AlbumCreateModal from './AlbumCreateModal'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { getServerUrl } from '../../hooks/getConstants' import { getServerUrl } from '../../hooks/getConstants'
import { useAccount } from '../../contexts/useAccount' import { useAccount } from '../../contexts/useAccount'
import { useNavigate } from 'react-router-dom'
import { useNotifier } from '../../contexts/useNotifier'
export default function AlbumList({ currentAlbumName }) { export default function AlbumList({ currentAlbumName }) {
const { getAccessToken } = useAccount() const { getAccessToken } = useAccount()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [albums, setAlbums] = useState([]) const [albums, setAlbums] = useState([])
const navigate = useNavigate()
const { showError } = useNotifier()
const getAlbums = async () => { const getAlbums = async () => {
console.log('Getting albums in parent', currentAlbumName) console.log('Getting albums in parent', currentAlbumName)
if (currentAlbumName === 'gallery') { // Root album if (currentAlbumName === 'gallery') { // Root album
@@ -20,10 +24,29 @@ export default function AlbumList({ currentAlbumName }) {
}), }),
}) })
const data = await response.json() const data = await response.json()
console.log('Albums', data) if (data.error) {
setAlbums(data) setAlbums([])
showError('Failed to get albums')
} else {
setAlbums(data)
}
} else { } 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(() => { useEffect(() => {
@@ -39,12 +62,26 @@ export default function AlbumList({ currentAlbumName }) {
</div> </div>
<div className="flex flex-row items-center justify-start gap-2 w-full px-6 flex-wrap"> <div className="flex flex-row items-center justify-start gap-2 w-full px-6 flex-wrap">
{albums.map((album) => ( {albums.map((album) => (
<div className="flex flex-row items-center justify-start gap-2 w-1/8 aspect-square border border-[#2B2B2B] rounded-md px-6 py-4"> <div
className="flex flex-row items-center justify-start gap-2 w-1/8 aspect-square border border-[#2B2B2B] rounded-md px-6 py-4 cursor-pointer"
onClick={() => {
// 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
}
}}
>
<p className="text-white red-hat-mono">{album.Title}</p> <p className="text-white red-hat-mono">{album.Title}</p>
</div> </div>
))} ))}
</div> </div>
<AlbumCreateModal open={open} onOpenChange={setOpen} /> <AlbumCreateModal open={open} onOpenChange={setOpen} parentId={currentAlbumName === 'gallery' ? '' : currentAlbumName} />
</div> </div>
) )
} }
+39 -3
View File
@@ -1,22 +1,58 @@
import NavBar from '../components/NavBar' import NavBar from '../components/NavBar'
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useAccount } from '../contexts/useAccount'; import { useAccount } from '../contexts/useAccount';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import AlbumList from './components/AlbumList'; import AlbumList from './components/AlbumList';
import { getServerUrl } from '../hooks/getConstants';
import { useNotifier } from '../contexts/useNotifier';
export default function Gallery() { export default function Gallery() {
const currentPath = useLocation().pathname; const currentPath = useLocation().pathname;
const pathList = currentPath.split('/').slice(1); const pathList = currentPath.split('/').slice(1);
const currentAlbumName = pathList[pathList.length - 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 { fetchUserData, user } = useAccount()
const { getAccessToken } = useAccount()
const { showError } = useNotifier()
useEffect(() => { useEffect(() => {
fetchUserData() 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 ( return (
<div className="flex flex-col items-center justify-start h-full w-full bg-[#141414]"> <div className="flex flex-col items-center justify-start h-full w-full bg-[#141414]">
<NavBar path={pathList} /> <NavBar path={pathList} />
<AlbumList currentAlbumName={currentAlbumName} /> <AlbumList currentAlbumName={currentAlbumID} />
</div> </div>
) )
} }