diff --git a/backend/database/models.py b/backend/database/models.py
index f77f47a..4fefe2b 100644
--- a/backend/database/models.py
+++ b/backend/database/models.py
@@ -24,6 +24,7 @@ class Asset(Base):
author_pennkey: Mapped[str]
keywords: Mapped[str]
image_uri: Mapped[Optional[str]]
+ archived: Mapped[bool] = mapped_column(insert_default=False)
versions: Mapped[list["Version"]] = relationship(back_populates="asset")
diff --git a/backend/migrations/versions/200e409da11c_add_archived_assets.py b/backend/migrations/versions/200e409da11c_add_archived_assets.py
new file mode 100644
index 0000000..f1c0934
--- /dev/null
+++ b/backend/migrations/versions/200e409da11c_add_archived_assets.py
@@ -0,0 +1,34 @@
+"""Add archived assets
+
+Revision ID: 200e409da11c
+Revises: 33e6aa9a48d7
+Create Date: 2024-04-29 16:12:48.187569
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "200e409da11c"
+down_revision: Union[str, None] = "33e6aa9a48d7"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column(
+ "assets",
+ sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column("assets", "archived")
+ # ### end Alembic commands ###
diff --git a/backend/routers/api_v1/assets.py b/backend/routers/api_v1/assets.py
index ba6886c..5452761 100644
--- a/backend/routers/api_v1/assets.py
+++ b/backend/routers/api_v1/assets.py
@@ -42,18 +42,26 @@
summary="Get a list of assets",
description="""Used for fetching a (paginated) list of assets stored in the database.
-Allows searching by arbitrary strings, sorting by date or name, adding keyword filters, and adding offset for pagination.""",
+Allows searching by arbitrary strings, sorting by date or name, adding keyword filters, and adding offset for pagination.
+
+The 'search' field allows fuzzy searching of most fields, whereas the 'keyword' field allows for a narrower search with multiple keywords.""",
)
def get_assets(
db: Annotated[Session, Depends(get_db)],
search: str | None = None,
keywords: str | None = None,
- sort: Literal["date_asc", "name_asc", "date_dsc", "name_dsc"] = "date_dsc",
+ sort: str | None = "name_asc",
offset: int = 0,
+ archived: bool | None = False,
) -> Sequence[Asset]:
# TODO: add fuzzy search somehow
return read_assets(
- db, search=(search if search != "" else None), offset=offset, sort=sort
+ db,
+ search=(search if search != "" else None),
+ keywords=(keywords if keywords != "" else None),
+ offset=offset,
+ sort=sort,
+ archived=archived,
)
@@ -111,22 +119,6 @@ async def put_asset(
return result
-@router.delete(
- "/{uuid}",
- summary="Delete asset metadata ONLY FOR DEV PURPOSES",
- description="Based on `uuid`, deletes a specific asset.",
-)
-async def delete_asset(
- db: Annotated[Session, Depends(get_db)],
- token: Annotated[str, Depends(oauth2_scheme)],
- uuid: str,
-):
- result = remove_asset(db, uuid)
- if result is False:
- raise HTTPException(status_code=404, detail="Asset not found")
- return
-
-
@router.get("/{uuid}/versions", summary="Get a list of versions for a given asset")
def get_asset_versions(
db: Annotated[Session, Depends(get_db)],
diff --git a/backend/schemas/models.py b/backend/schemas/models.py
index 9fe510e..24da0d4 100644
--- a/backend/schemas/models.py
+++ b/backend/schemas/models.py
@@ -7,6 +7,7 @@
class AssetBase(BaseModel):
keywords: str
image_uri: Optional[str]
+ archived: bool = False
class AssetCreate(AssetBase):
diff --git a/backend/util/crud/assets.py b/backend/util/crud/assets.py
index acb56b3..833ce00 100644
--- a/backend/util/crud/assets.py
+++ b/backend/util/crud/assets.py
@@ -18,8 +18,7 @@
from util.files import temp_s3_download
from util.s3 import assets_bucket
-from sqlalchemy import or_, func
-from sqlalchemy.sql.expression import case
+from sqlalchemy import or_, and_
# https://fastapi.tiangolo.com/tutorial/sql-databases/#crud-utils
@@ -31,8 +30,10 @@ def read_asset(db: Session, asset_id: str):
def read_assets(
db: Session,
search: str | None = None,
+ keywords: str | None = None,
offset=0,
- sort: Literal["date_asc", "name_asc", "date_dsc", "name_dsc"] = "date_dsc",
+ sort: str | None = "name_asc",
+ archived: bool | None = False,
):
# TODO: figure out the join nonsense
# 1. distance between str and asset name should be small
@@ -44,26 +45,35 @@ def read_assets(
# If search contains commas, split by commas, otherwise split by spaces
if "," in search:
reader = csv.reader([search], skipinitialspace=True)
- keywords = next(reader)
+ keywordList = next(reader)
else:
- keywords = search.split()
- # check if asset name contains search
- asset_name_conditions = []
- asset_name_conditions.append(
- or_(
- *[Asset.asset_name.ilike(f"%{search}%")],
- *[Asset.asset_name.ilike(f"%{kw}%") for kw in keywords],
- )
- )
+ keywordList = search.split()
+
# check if keywords or author contain search words
query = query.filter(
or_(
- *asset_name_conditions,
- *[Asset.keywords.ilike(f"%{kw}%") for kw in keywords],
- *[Asset.author_pennkey.ilike(f"%{search}%")],
+ Asset.asset_name.ilike(f"%{search}%"),
+ *[Asset.asset_name.ilike(f"%{kw}%") for kw in keywordList],
+ *[Asset.keywords.ilike(f"%{kw}%") for kw in keywordList],
+ Asset.author_pennkey.ilike(f"%{search}%"),
)
)
+ if keywords is not None:
+ # split by commas
+ reader = csv.reader([keywords], skipinitialspace=True)
+ keywordList = next(reader)
+
+ # check if keywords
+ query = query.filter(
+ and_(*[Asset.keywords.ilike(f"%{kw}%") for kw in keywordList])
+ )
+
+ if archived is not None:
+ if archived is False:
+ query = query.filter(Asset.archived == False)
+ # query = query.filter(Asset.archived == True)
+
# sort by date or name
# if sort == "date_asc":
# query = query.order_by(Version.date.asc())
@@ -94,6 +104,7 @@ def create_asset(db: Session, asset: AssetCreate, author_pennkey: str):
author_pennkey=author_pennkey,
keywords=asset.keywords,
image_uri=asset.image_uri,
+ archived=asset.archived,
)
db.add(db_asset)
db.commit()
@@ -111,6 +122,7 @@ def update_asset(db: Session, asset_id: str, asset: AssetUpdate):
return None
db_asset.keywords = asset.keywords
db_asset.image_uri = asset.image_uri
+ db_asset.archived = asset.archived
db.commit()
db.refresh(db_asset)
return db_asset
diff --git a/backend/util/sqladmin.py b/backend/util/sqladmin.py
index 825c0a4..4bb89ff 100644
--- a/backend/util/sqladmin.py
+++ b/backend/util/sqladmin.py
@@ -10,6 +10,7 @@ class AssetAdmin(ModelView, model=Asset):
Asset.asset_name,
Asset.author_pennkey,
Asset.keywords,
+ Asset.archived,
Asset.versions,
]
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 0c1167d..8ae5502 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -54,6 +54,7 @@
"swr": "^2.2.5",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
+ "usehooks-ts": "^3.1.0",
"vite": "^5.0.12",
"zod": "^3.22.5",
"zustand": "^4.5.2"
@@ -7285,6 +7286,12 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true
+ },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -10233,6 +10240,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/usehooks-ts": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz",
+ "integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==",
+ "dev": true,
+ "dependencies": {
+ "lodash.debounce": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=16.15.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18"
+ }
+ },
"node_modules/utf8-byte-length": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 178c7ee..22150be 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -67,6 +67,7 @@
"swr": "^2.2.5",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
+ "usehooks-ts": "^3.1.0",
"vite": "^5.0.12",
"zod": "^3.22.5",
"zustand": "^4.5.2"
diff --git a/frontend/src/renderer/src/components/asset-entry.tsx b/frontend/src/renderer/src/components/asset-entry.tsx
index b83357a..942ae17 100644
--- a/frontend/src/renderer/src/components/asset-entry.tsx
+++ b/frontend/src/renderer/src/components/asset-entry.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { MdCheckCircle, MdDownload, MdDownloading, MdLogin } from 'react-icons/md';
+import { MdArchive, MdCheckCircle, MdDownload, MdDownloading, MdLogin } from 'react-icons/md';
import { useAssetSelectStore } from '@renderer/hooks/use-asset-select';
import useAuth from '@renderer/hooks/use-auth';
@@ -10,7 +10,7 @@ import funnygif from '../assets/funny.gif';
import { useNavigate } from 'react-router-dom';
export default function AssetEntry({
- asset: { asset_name, author_pennkey, id, image_uri },
+ asset: { asset_name, author_pennkey, id, image_uri, archived },
isDownloaded,
}: {
asset: Asset;
@@ -74,9 +74,16 @@ export default function AssetEntry({
)}
-
- {asset_name}
-
{author_pennkey}
+
+
+ {asset_name}
+
{author_pennkey}
+
+ {archived && (
+
+
+
+ )}
diff --git a/frontend/src/renderer/src/components/forms/new-asset-form.tsx b/frontend/src/renderer/src/components/forms/new-asset-form.tsx
index a95f37e..b480a5c 100644
--- a/frontend/src/renderer/src/components/forms/new-asset-form.tsx
+++ b/frontend/src/renderer/src/components/forms/new-asset-form.tsx
@@ -76,6 +76,7 @@ export default function NewAssetForm({ afterSubmit }: { afterSubmit?: SubmitHand
asset_name: data.assetName,
keywords: data.keywords.map(({ keyword }) => keyword).join(','),
image_uri,
+ archived: false,
},
headers: { Authorization: `Bearer ${await getAuthToken()}` },
});
@@ -86,6 +87,7 @@ export default function NewAssetForm({ afterSubmit }: { afterSubmit?: SubmitHand
toast.error(err instanceof Error ? `${err.message}.` : 'Something went wrong.');
return;
}
+
// Create initial version for the asset
await window.api.ipc('assets:create-initial-version', {
asset_id: responseData.id,
diff --git a/frontend/src/renderer/src/components/input/filter-keywords-input.tsx b/frontend/src/renderer/src/components/input/filter-keywords-input.tsx
new file mode 100644
index 0000000..17790a3
--- /dev/null
+++ b/frontend/src/renderer/src/components/input/filter-keywords-input.tsx
@@ -0,0 +1,75 @@
+import { useEffect, useState } from 'react';
+import { HiPlus, HiXMark } from 'react-icons/hi2';
+
+import Label from './label';
+
+interface ControlledInputProps {
+ inputName: string;
+ inputs: string[];
+ setInputs: (query: string[]) => void;
+}
+
+function ControlledInput({ inputName, inputs, setInputs }: ControlledInputProps) {
+ const [inputText, setInputText] = useState('');
+
+ useEffect(() => {
+ // Update internal state when store's keywords change
+ setInputText(''); // Clear input after store update (optional)
+ }, [inputs]);
+
+ const addInput = () => {
+ const formatted = inputText.trim().toLowerCase();
+ if (formatted.length === 0 || inputs.includes(formatted)) return;
+ const updatedInputs = [...inputs, formatted];
+ setInputs(updatedInputs);
+ setInputText('');
+ };
+
+ const removeInput = (index) => {
+ // setKeywords(keywords.filter((_, i) => i !== index));
+ const updatedInputs = inputs.filter((_, i) => i !== index);
+ setInputs(updatedInputs);
+ };
+
+ return (
+
+ );
+}
+
+export default ControlledInput;
diff --git a/frontend/src/renderer/src/components/layout/navbar-filter.tsx b/frontend/src/renderer/src/components/layout/navbar-filter.tsx
index 9372ee3..5bfff0a 100644
--- a/frontend/src/renderer/src/components/layout/navbar-filter.tsx
+++ b/frontend/src/renderer/src/components/layout/navbar-filter.tsx
@@ -1,105 +1,98 @@
-import { useState } from 'react';
+import { AnimatePresence, motion } from 'framer-motion';
+import { useRef, useState } from 'react';
import { CiFilter } from 'react-icons/ci';
+import { MdArchive } from 'react-icons/md';
+import { useOnClickOutside } from 'usehooks-ts';
-export type AssetFilters = {
- nameFilter: string;
- contributorFilter: string;
- keywordsFilter: string;
- dateRange: { start: string; end: string };
-};
+import { useSearchParamsStore } from '@renderer/hooks/use-assets-search';
+import ControlledKeywordsInput from '../input/filter-keywords-input';
+import Label from '../input/label';
-const NavbarFilter = ({ onApply }: { onApply: (filters: AssetFilters) => void }) => {
- // States for filter criteria
- const [nameFilter, setNameFilter] = useState('');
- const [contributorFilter, setContributorFilter] = useState('');
- const [keywordsFilter, setKeywordsFilter] = useState('');
- const [dateRange, setDateRange] = useState({ start: '', end: '' });
+const NavbarFilter = () => {
const [dropdownVisible, setDropdownVisible] = useState(false); // State to control dropdown visibility
- const applyFilters = () => {
- onApply({ nameFilter, contributorFilter, keywordsFilter, dateRange });
- setDropdownVisible(false);
- };
+ const { keywords, archived, setKeywords, setArchived } = useSearchParamsStore();
+ const { sort, setSort } = useSearchParamsStore();
+
+ const ref = useRef
(null!);
+ useOnClickOutside(ref, () => setDropdownVisible(false));
- // Toggle dropdown visibility
- const toggleDropdown = () => {
- setDropdownVisible(!dropdownVisible);
- };
+ // how many "active filters" we have
+ const filterCount = (archived ? 1 : 0) + keywords.length;
return (
-
-
+ setDropdownVisible((v) => !v)}
>
Filter
-
- {dropdownVisible && ( // Conditionally render the dropdown based on its visibility state
-
-
-
-
-
- )}
+
+
+
+ )}
+
);
};
diff --git a/frontend/src/renderer/src/components/layout/navbar.tsx b/frontend/src/renderer/src/components/layout/navbar.tsx
index 6a26036..d124d29 100644
--- a/frontend/src/renderer/src/components/layout/navbar.tsx
+++ b/frontend/src/renderer/src/components/layout/navbar.tsx
@@ -6,7 +6,7 @@ import { themeChange } from 'theme-change';
import { useSearchParamsStore } from '@renderer/hooks/use-assets-search';
import useAuth from '@renderer/hooks/use-auth';
-import NavbarFilter, { AssetFilters } from './navbar-filter';
+import NavbarFilter from './navbar-filter';
import ThemeSelector from './theme-selector';
import UserDropdown from './user-dropdown';
@@ -21,11 +21,6 @@ const Navbar = () => {
themeChange(false);
}, []);
- const handleApplyFilters = (filters: AssetFilters) => {
- console.log('Applying filters:', filters);
- // TODO: Implement logic to filter your data based on the filters object
- };
-
return (
{/* Filter Component */}
-
+
diff --git a/frontend/src/renderer/src/components/metadata.tsx b/frontend/src/renderer/src/components/metadata.tsx
index cd89612..963c5be 100644
--- a/frontend/src/renderer/src/components/metadata.tsx
+++ b/frontend/src/renderer/src/components/metadata.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { CiEdit } from 'react-icons/ci';
-import { MdFolderOpen, MdLogin, MdSync, MdSyncDisabled } from 'react-icons/md';
+import { MdArchive, MdFolderOpen, MdLogin, MdSync, MdSyncDisabled } from 'react-icons/md';
import { Link, useNavigate } from 'react-router-dom';
import { useSelectedAsset } from '@renderer/hooks/use-asset-select';
@@ -56,8 +56,9 @@ export default function Metadata() {
};
const handleInputChange = (e: React.ChangeEvent, field: keyof Asset) => {
+ const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
if (!editedAsset) return;
- setEditedAsset({ ...editedAsset, [field]: e.target.value });
+ setEditedAsset({ ...editedAsset, [field]: value });
};
const handleSaveClick = async (data: UpdateMetadataData) => {
@@ -88,6 +89,7 @@ export default function Metadata() {
asset_name: editedAsset.asset_name,
keywords: editedAsset.keywords,
image_uri,
+ archived: editedAsset.archived,
},
params: {
path: {
@@ -105,6 +107,7 @@ export default function Metadata() {
asset.asset_name = editedAsset.asset_name;
asset.keywords = editedAsset.keywords;
asset.image_uri = image_uri;
+ asset.archived = editedAsset.archived;
data.thumbnailFile = undefined;
@@ -153,6 +156,12 @@ export default function Metadata() {
// If an asset is selected, render its information
return (
+ {asset.archived && !editMode && (
+
+
+ This asset is archived.
+
+ )}
Metadata
{!editMode && (
@@ -237,6 +246,22 @@ export default function Metadata() {
)}
/>
+
+
+
+
+ Archived
+
+ handleInputChange(e, 'archived')}
+ className={`toggle ${editedAsset?.archived ? 'toggle-warning' : ''}`}
+ />
+
+