mirror of
https://github.com/wisplite/raster.git
synced 2026-05-01 06:32:44 -05:00
working album navigation and duplicate checks on backend
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user