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 ( +
{/* 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() {
)} /> +
+ +