mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
Merge branch 'main' into feat/timeline-return-to-start
This commit is contained in:
@@ -17,12 +17,15 @@ interface PanelState {
|
||||
mainContent: number;
|
||||
timeline: number;
|
||||
|
||||
mediaViewMode: "grid" | "list";
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size: number) => void;
|
||||
setPreviewPanel: (size: number) => void;
|
||||
setPropertiesPanel: (size: number) => void;
|
||||
setMainContent: (size: number) => void;
|
||||
setTimeline: (size: number) => void;
|
||||
setMediaViewMode: (mode: "grid" | "list") => void;
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
@@ -31,12 +34,15 @@ export const usePanelStore = create<PanelState>()(
|
||||
// Default sizes - optimized for responsiveness
|
||||
...DEFAULT_PANEL_SIZES,
|
||||
|
||||
mediaViewMode: "grid" as const,
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size) => set({ toolsPanel: size }),
|
||||
setPreviewPanel: (size) => set({ previewPanel: size }),
|
||||
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
|
||||
setMainContent: (size) => set({ mainContent: size }),
|
||||
setTimeline: (size) => set({ timeline: size }),
|
||||
setMediaViewMode: (mode) => set({ mediaViewMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { PlaybackState, PlaybackControls } from "@/types/playback";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useProjectStore } from "./project-store";
|
||||
|
||||
interface PlaybackStore extends PlaybackState, PlaybackControls {
|
||||
setDuration: (duration: number) => void;
|
||||
@@ -20,13 +22,33 @@ const startTimer = (store: () => PlaybackStore) => {
|
||||
lastUpdate = now;
|
||||
|
||||
const newTime = state.currentTime + delta * state.speed;
|
||||
if (newTime >= state.duration) {
|
||||
// When video completes, pause and reset playhead to start
|
||||
|
||||
// Get actual content duration from timeline store
|
||||
const actualContentDuration = useTimelineStore
|
||||
.getState()
|
||||
.getTotalDuration();
|
||||
|
||||
// Stop at actual content end, not timeline duration (which has 10s minimum)
|
||||
// It was either this or reducing default min timeline to 1 second
|
||||
const effectiveDuration =
|
||||
actualContentDuration > 0 ? actualContentDuration : state.duration;
|
||||
|
||||
if (newTime >= effectiveDuration) {
|
||||
// When content completes, pause just before the end so we can see the last frame
|
||||
const projectFps = useProjectStore.getState().activeProject?.fps;
|
||||
if (!projectFps)
|
||||
console.error("Project FPS is not set, assuming 30fps");
|
||||
|
||||
const frameOffset = 1 / (projectFps ?? 30); // Stop 1 frame before end based on project FPS
|
||||
const stopTime = Math.max(0, effectiveDuration - frameOffset);
|
||||
|
||||
state.pause();
|
||||
state.setCurrentTime(0);
|
||||
// Notify video elements to sync with reset
|
||||
state.setCurrentTime(stopTime);
|
||||
// Notify video elements to sync with end position
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", { detail: { time: 0 } })
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time: stopTime },
|
||||
})
|
||||
);
|
||||
} else {
|
||||
state.setCurrentTime(newTime);
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ProjectStore {
|
||||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
invalidProjectIds?: Set<string>;
|
||||
|
||||
// Actions
|
||||
createNewProject: (name: string) => Promise<string>;
|
||||
@@ -28,10 +29,20 @@ interface ProjectStore {
|
||||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
|
||||
// Bookmark methods
|
||||
toggleBookmark: (time: number) => Promise<void>;
|
||||
isBookmarked: (time: number) => boolean;
|
||||
removeBookmark: (time: number) => Promise<void>;
|
||||
|
||||
getFilteredAndSortedProjects: (
|
||||
searchQuery: string,
|
||||
sortOption: string
|
||||
) => TProject[];
|
||||
|
||||
// Global invalid project ID tracking
|
||||
isInvalidProjectId: (id: string) => boolean;
|
||||
markProjectIdAsInvalid: (id: string) => void;
|
||||
clearInvalidProjectIds: () => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
@@ -39,6 +50,98 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
invalidProjectIds: new Set<string>(),
|
||||
|
||||
// Implementation of bookmark methods
|
||||
toggleBookmark: async (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || 30;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
const bookmarks = activeProject.bookmarks || [];
|
||||
let updatedBookmarks: number[];
|
||||
|
||||
// Check if already bookmarked
|
||||
const bookmarkIndex = bookmarks.findIndex(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
|
||||
);
|
||||
|
||||
if (bookmarkIndex !== -1) {
|
||||
// Remove bookmark
|
||||
updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex);
|
||||
} else {
|
||||
// Add bookmark
|
||||
updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
bookmarks: updatedBookmarks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project bookmarks:", error);
|
||||
toast.error("Failed to update bookmarks", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
isBookmarked: (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject || !activeProject.bookmarks) return false;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || 30;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
return activeProject.bookmarks.some(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) < 0.001
|
||||
);
|
||||
},
|
||||
|
||||
removeBookmark: async (time: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject || !activeProject.bookmarks) return;
|
||||
|
||||
// Round time to the nearest frame
|
||||
const fps = activeProject.fps || 30;
|
||||
const frameTime = Math.round(time * fps) / fps;
|
||||
|
||||
const updatedBookmarks = activeProject.bookmarks.filter(
|
||||
(bookmark) => Math.abs(bookmark - frameTime) >= 0.001
|
||||
);
|
||||
|
||||
if (updatedBookmarks.length === activeProject.bookmarks.length) {
|
||||
// No bookmark found to remove
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
bookmarks: updatedBookmarks,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project bookmarks:", error);
|
||||
toast.error("Failed to remove bookmark", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject: TProject = {
|
||||
@@ -50,6 +153,8 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
bookmarks: [],
|
||||
fps: 30,
|
||||
};
|
||||
|
||||
set({ activeProject: newProject });
|
||||
@@ -230,9 +335,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProject: TProject = {
|
||||
...project, // Copy all properties from the original project
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
thumbnail: project.thumbnail,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -357,4 +462,23 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
|
||||
return sortedProjects;
|
||||
},
|
||||
|
||||
// Global invalid project ID tracking implementation
|
||||
isInvalidProjectId: (id: string) => {
|
||||
const invalidIds = get().invalidProjectIds || new Set();
|
||||
return invalidIds.has(id);
|
||||
},
|
||||
|
||||
markProjectIdAsInvalid: (id: string) => {
|
||||
set((state) => ({
|
||||
invalidProjectIds: new Set([
|
||||
...(state.invalidProjectIds || new Set()),
|
||||
id,
|
||||
]),
|
||||
}));
|
||||
},
|
||||
|
||||
clearInvalidProjectIds: () => {
|
||||
set({ invalidProjectIds: new Set() });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { create } from "zustand";
|
||||
import type { SoundEffect, SavedSound } from "@/types/sounds";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { useProjectStore } from "./project-store";
|
||||
import { usePlaybackStore } from "./playback-store";
|
||||
|
||||
interface SoundsStore {
|
||||
topSoundEffects: SoundEffect[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
hasLoaded: boolean;
|
||||
|
||||
// Filter state
|
||||
showCommercialOnly: boolean;
|
||||
toggleCommercialFilter: () => void;
|
||||
|
||||
// Search state
|
||||
searchQuery: string;
|
||||
searchResults: SoundEffect[];
|
||||
isSearching: boolean;
|
||||
searchError: string | null;
|
||||
lastSearchQuery: string;
|
||||
scrollPosition: number;
|
||||
|
||||
// Pagination state
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
totalCount: number;
|
||||
isLoadingMore: boolean;
|
||||
|
||||
// Saved sounds state
|
||||
savedSounds: SavedSound[];
|
||||
isSavedSoundsLoaded: boolean;
|
||||
isLoadingSavedSounds: boolean;
|
||||
savedSoundsError: string | null;
|
||||
|
||||
// Timeline integration
|
||||
addSoundToTimeline: (sound: SoundEffect) => Promise<boolean>;
|
||||
|
||||
setTopSoundEffects: (sounds: SoundEffect[]) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setHasLoaded: (loaded: boolean) => void;
|
||||
|
||||
// Search actions
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSearchResults: (results: SoundEffect[]) => void;
|
||||
setSearching: (searching: boolean) => void;
|
||||
setSearchError: (error: string | null) => void;
|
||||
setLastSearchQuery: (query: string) => void;
|
||||
setScrollPosition: (position: number) => void;
|
||||
|
||||
// Pagination actions
|
||||
setCurrentPage: (page: number) => void;
|
||||
setHasNextPage: (hasNext: boolean) => void;
|
||||
setTotalCount: (count: number) => void;
|
||||
setLoadingMore: (loading: boolean) => void;
|
||||
appendSearchResults: (results: SoundEffect[]) => void;
|
||||
appendTopSounds: (results: SoundEffect[]) => void;
|
||||
resetPagination: () => void;
|
||||
|
||||
// Saved sounds actions
|
||||
loadSavedSounds: () => Promise<void>;
|
||||
saveSoundEffect: (soundEffect: SoundEffect) => Promise<void>;
|
||||
removeSavedSound: (soundId: number) => Promise<void>;
|
||||
isSoundSaved: (soundId: number) => boolean;
|
||||
toggleSavedSound: (soundEffect: SoundEffect) => Promise<void>;
|
||||
clearSavedSounds: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSoundsStore = create<SoundsStore>((set, get) => ({
|
||||
topSoundEffects: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
showCommercialOnly: true,
|
||||
|
||||
toggleCommercialFilter: () => {
|
||||
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
|
||||
},
|
||||
|
||||
// Search state
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
isSearching: false,
|
||||
searchError: null,
|
||||
lastSearchQuery: "",
|
||||
scrollPosition: 0,
|
||||
|
||||
// Pagination state
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
|
||||
// Saved sounds state
|
||||
savedSounds: [],
|
||||
isSavedSoundsLoaded: false,
|
||||
isLoadingSavedSounds: false,
|
||||
savedSoundsError: null,
|
||||
|
||||
setTopSoundEffects: (sounds) => set({ topSoundEffects: sounds }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setHasLoaded: (loaded) => set({ hasLoaded: loaded }),
|
||||
|
||||
// Search actions
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSearchResults: (results) =>
|
||||
set({ searchResults: results, currentPage: 1 }),
|
||||
setSearching: (searching) => set({ isSearching: searching }),
|
||||
setSearchError: (error) => set({ searchError: error }),
|
||||
setLastSearchQuery: (query) => set({ lastSearchQuery: query }),
|
||||
setScrollPosition: (position) => set({ scrollPosition: position }),
|
||||
|
||||
// Pagination actions
|
||||
setCurrentPage: (page) => set({ currentPage: page }),
|
||||
setHasNextPage: (hasNext) => set({ hasNextPage: hasNext }),
|
||||
setTotalCount: (count) => set({ totalCount: count }),
|
||||
setLoadingMore: (loading) => set({ isLoadingMore: loading }),
|
||||
appendSearchResults: (results) =>
|
||||
set((state) => ({
|
||||
searchResults: [...state.searchResults, ...results],
|
||||
})),
|
||||
appendTopSounds: (results) =>
|
||||
set((state) => ({
|
||||
topSoundEffects: [...state.topSoundEffects, ...results],
|
||||
})),
|
||||
resetPagination: () =>
|
||||
set({
|
||||
currentPage: 1,
|
||||
hasNextPage: false,
|
||||
totalCount: 0,
|
||||
isLoadingMore: false,
|
||||
}),
|
||||
|
||||
// Saved sounds actions
|
||||
loadSavedSounds: async () => {
|
||||
if (get().isSavedSoundsLoaded) return;
|
||||
|
||||
try {
|
||||
set({ isLoadingSavedSounds: true, savedSoundsError: null });
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({
|
||||
savedSounds: savedSoundsData.sounds,
|
||||
isSavedSoundsLoaded: true,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to load saved sounds";
|
||||
set({
|
||||
savedSoundsError: errorMessage,
|
||||
isLoadingSavedSounds: false,
|
||||
});
|
||||
console.error("Failed to load saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
saveSoundEffect: async (soundEffect: SoundEffect) => {
|
||||
try {
|
||||
await storageService.saveSoundEffect(soundEffect);
|
||||
|
||||
// Refresh saved sounds
|
||||
const savedSoundsData = await storageService.loadSavedSounds();
|
||||
set({ savedSounds: savedSoundsData.sounds });
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to save sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to save sound");
|
||||
console.error("Failed to save sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
removeSavedSound: async (soundId: number) => {
|
||||
try {
|
||||
await storageService.removeSavedSound(soundId);
|
||||
|
||||
// Update local state immediately
|
||||
set((state) => ({
|
||||
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to remove sound";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to remove sound");
|
||||
console.error("Failed to remove sound:", error);
|
||||
}
|
||||
},
|
||||
|
||||
isSoundSaved: (soundId: number) => {
|
||||
const { savedSounds } = get();
|
||||
return savedSounds.some((sound) => sound.id === soundId);
|
||||
},
|
||||
|
||||
toggleSavedSound: async (soundEffect: SoundEffect) => {
|
||||
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
|
||||
|
||||
if (isSoundSaved(soundEffect.id)) {
|
||||
await removeSavedSound(soundEffect.id);
|
||||
} else {
|
||||
await saveSoundEffect(soundEffect);
|
||||
}
|
||||
},
|
||||
|
||||
clearSavedSounds: async () => {
|
||||
try {
|
||||
await storageService.clearSavedSounds();
|
||||
set({
|
||||
savedSounds: [],
|
||||
savedSoundsError: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to clear saved sounds";
|
||||
set({ savedSoundsError: errorMessage });
|
||||
toast.error("Failed to clear saved sounds");
|
||||
console.error("Failed to clear saved sounds:", error);
|
||||
}
|
||||
},
|
||||
|
||||
addSoundToTimeline: async (sound) => {
|
||||
const activeProject = useProjectStore.getState().activeProject;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return false;
|
||||
}
|
||||
|
||||
const audioUrl = sound.previewUrl;
|
||||
if (!audioUrl) {
|
||||
toast.error("Sound file not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(audioUrl);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to download audio: ${response.statusText}`);
|
||||
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `${sound.name}.mp3`, {
|
||||
type: "audio/mpeg",
|
||||
});
|
||||
|
||||
await useMediaStore.getState().addMediaItem(activeProject.id, {
|
||||
name: sound.name,
|
||||
type: "audio",
|
||||
file,
|
||||
duration: sound.duration,
|
||||
url: URL.createObjectURL(file),
|
||||
});
|
||||
|
||||
const mediaItem = useMediaStore
|
||||
.getState()
|
||||
.mediaItems.find((item) => item.file === file);
|
||||
if (!mediaItem) throw new Error("Failed to create media item");
|
||||
|
||||
const success = useTimelineStore
|
||||
.getState()
|
||||
.addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime);
|
||||
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
throw new Error("Failed to add to timeline - check for overlaps");
|
||||
} catch (error) {
|
||||
console.error("Failed to add sound to timeline:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to add sound to timeline",
|
||||
{ id: `sound-${sound.id}` }
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -126,6 +126,7 @@ interface TimelineStore {
|
||||
pushHistory?: boolean
|
||||
) => void;
|
||||
toggleTrackMute: (trackId: string) => void;
|
||||
toggleElementHidden: (trackId: string, elementId: string) => void;
|
||||
|
||||
// Split operations for elements
|
||||
splitElement: (
|
||||
@@ -868,6 +869,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
);
|
||||
},
|
||||
|
||||
toggleElementHidden: (trackId, elementId) => {
|
||||
get().pushHistory();
|
||||
updateTracksAndSave(
|
||||
get()._tracks.map((track) =>
|
||||
track.id === trackId
|
||||
? {
|
||||
...track,
|
||||
elements: track.elements.map((element) =>
|
||||
element.id === elementId
|
||||
? { ...element, hidden: !element.hidden }
|
||||
: element
|
||||
),
|
||||
}
|
||||
: track
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
updateTextElement: (trackId, elementId, updates) => {
|
||||
get().pushHistory();
|
||||
updateTracksAndSave(
|
||||
@@ -1426,16 +1445,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
|
||||
addMediaAtTime: (item, currentTime = 0) => {
|
||||
const trackType = item.type === "audio" ? "audio" : "media";
|
||||
const targetTrackId = get().findOrCreateTrack(trackType);
|
||||
|
||||
const duration =
|
||||
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION;
|
||||
|
||||
if (get().checkElementOverlap(targetTrackId, currentTime, duration)) {
|
||||
toast.error(
|
||||
"Cannot place element here - it would overlap with existing elements"
|
||||
);
|
||||
return false;
|
||||
// Get all tracks of the right type
|
||||
const tracks = get()._tracks.filter((t) => t.type === trackType);
|
||||
|
||||
// Try to find a track with no overlap
|
||||
let targetTrackId = null;
|
||||
for (const track of tracks) {
|
||||
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
|
||||
targetTrackId = track.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no free track found, create a new one
|
||||
if (!targetTrackId) {
|
||||
targetTrackId = get().addTrack(trackType);
|
||||
}
|
||||
|
||||
get().addElementToTrack(targetTrackId, {
|
||||
|
||||
Reference in New Issue
Block a user