diff --git a/backend/api/auth.py b/backend/api/auth.py index 0bf3193..6b9f0e3 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -1,4 +1,4 @@ -from fastapi import Depends, Header, Response +from fastapi import Depends, Header, Response, Request from pydantic import Field import nebula @@ -18,7 +18,7 @@ class LoginRequestModel(RequestModel): ..., title="Username", example="admin", - regex=r"^[a-zA-Z0-9_\-\.]{3,}$", + regex=r"^[a-zA-Z0-9_\-\.]{2,}$", ) password: str = Field( ..., @@ -53,9 +53,13 @@ class LoginRequest(APIRequest): name: str = "login" response_model = LoginResponseModel - async def handle(self, request: LoginRequestModel) -> LoginResponseModel: - user = await nebula.User.login(request.username, request.password) - session = await Session.create(user) + async def handle( + self, + request: Request, + payload: LoginRequestModel, + ) -> LoginResponseModel: + user = await nebula.User.login(payload.username, payload.password) + session = await Session.create(user, request) return LoginResponseModel(access_token=session.token) diff --git a/backend/api/browse.py b/backend/api/browse.py index b5610ca..e3a88ad 100644 --- a/backend/api/browse.py +++ b/backend/api/browse.py @@ -26,6 +26,7 @@ "ctime", "mtime", "video/fps_f", + "subclips", ] # @@ -221,6 +222,10 @@ def build_query( c2 = f"meta->'assignees' @> '[{user.id}]'::JSONB" cond_list.append(f"({c1} OR {c2})") + if can_view := user["can/asset_view"]: + if type(can_view) is list: + cond_list.append(f"id_folder IN {sql_list(can_view)}") + # Build conditions if cond_list: diff --git a/backend/api/jobs/jobs.py b/backend/api/jobs/jobs.py index 2fd4ff0..8ac9d3d 100644 --- a/backend/api/jobs/jobs.py +++ b/backend/api/jobs/jobs.py @@ -52,6 +52,13 @@ class JobsRequestModel(ResponseModel): title="Restart", description="Restart job with given id", ) + priority: tuple[int, int] | None = Field( + None, + title="Priority", + descriprion="Set priority of job with given id. " + "First value is job id, second is priority", + example=[42, 3], + ) class JobsItemModel(RequestModel): @@ -66,7 +73,8 @@ class JobsItemModel(RequestModel): title="User ID", description="ID of the user who started the job", ) - message: str = Field(None, title="Status description", example="Encoding 24%") + priority: int = Field(3, title="Priority", example=3) + message: str = Field(..., title="Status description", example="Encoding 24%") ctime: int | None = Field(None, title="Created at", example=f"{int(time.time())}") stime: int | None = Field(None, title="Started at", example=f"{int(time.time())}") etime: int | None = Field(None, title="Finished at", example=f"{int(time.time())}") @@ -161,6 +169,18 @@ async def abort_job(id_job: int, user: nebula.User) -> None: ) +async def set_priority(id_job: int, priority: int, user: nebula.User) -> None: + if not await can_user_control_job(user, id_job): + raise nebula.ForbiddenException("You cannot set priority of this job") + nebula.log.info(f"Setting priority of job {id_job} to {priority}", user=user.name) + query = """ + UPDATE jobs SET + priority = $1 + WHERE id = $2 + """ + await nebula.db.execute(query, priority, id_job) + + class JobsRequest(APIRequest): """List and control jobs""" @@ -180,6 +200,9 @@ async def handle( if request.restart: await restart_job(request.restart, user) + if request.priority is not None: + await set_priority(request.priority[0], request.priority[1], user) + # Return a list of jobs if requested conds = [] @@ -223,6 +246,7 @@ async def handle( j.id_user AS id_user, j.status AS status, j.progress AS progress, + j.priority AS priority, j.message AS message, j.creation_time AS ctime, j.start_time AS stime, diff --git a/backend/api/rundown/models.py b/backend/api/rundown/models.py index 6e97dd7..66cf346 100644 --- a/backend/api/rundown/models.py +++ b/backend/api/rundown/models.py @@ -17,6 +17,7 @@ class RundownRow(ResponseModel): id: int = Field(...) type: Literal["item", "event"] = Field(...) id_bin: int = Field(...) + id_event: int = Field(...) scheduled_time: float = Field(...) broadcast_time: float = Field(...) meta: dict[str, Any] | None = Field(None) @@ -35,6 +36,7 @@ class RundownRow(ResponseModel): loop: bool | None = Field(None) item_role: ItemMode | None = Field(None) is_empty: bool = Field(True) + is_primary: bool = Field(False) class RundownResponseModel(ResponseModel): diff --git a/backend/api/rundown/rundown.py b/backend/api/rundown/rundown.py index 8257d20..76519f4 100644 --- a/backend/api/rundown/rundown.py +++ b/backend/api/rundown/rundown.py @@ -82,6 +82,7 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel: subtitle=event.get("subtitle"), id_asset=event.get("id_asset"), id_bin=id_bin, + id_event=id_event, meta=emeta, ) @@ -151,10 +152,21 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel: if asset: for key in channel.rundown_columns: if (key in asset.meta) and ( - key not in ["title", "subtitle", "id_asset", "duration", "status"] + key + not in [ + "title", + "subtitle", + "id_asset", + "duration", + "status", + "mark_in", + "mark_out", + ] ): meta[key] = asset.meta[key] + id_asset = imeta.get("id_asset") + primary = bool(event.get("id_asset") and (event["id_asset"] == id_asset)) row = RundownRow( id=id_item, row_number=len(rows), @@ -166,8 +178,9 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel: item_role=imeta.get("item_role"), title=item["title"], subtitle=item["subtitle"], - id_asset=imeta.get("id_asset"), + id_asset=id_asset, id_bin=id_bin, + id_event=id_event, duration=duration, status=istatus, transfer_progress=transfer_progress, @@ -175,6 +188,7 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel: mark_in=mark_in, mark_out=mark_out, is_empty=False, + is_primary=primary, meta=meta, ) diff --git a/backend/api/scheduler/scheduler.py b/backend/api/scheduler/scheduler.py index 99312cf..90f904b 100644 --- a/backend/api/scheduler/scheduler.py +++ b/backend/api/scheduler/scheduler.py @@ -156,8 +156,9 @@ async def scheduler( # update the event event_at_position["id_asset"] = event_data.id_asset for field in channel.fields: - if field.name in asset.meta: - event_at_position[field.name] = asset.meta[field.name] + if field.name in ["color", "start", "stop", "promoted"]: + continue + event_at_position[field.name] = asset[field.name] affected_events.append(event_at_position.id) await ex_bin.save() await event_at_position.save() diff --git a/backend/api/sessions.py b/backend/api/sessions.py new file mode 100644 index 0000000..0132392 --- /dev/null +++ b/backend/api/sessions.py @@ -0,0 +1,41 @@ +import nebula + +from fastapi import Query +from server.session import SessionModel, Session +from server.dependencies import CurrentUser +from server.request import APIRequest +from server.models import RequestModel + +class SessionsRequest(RequestModel): + id_user: int = Query(..., example=1) + + +class Sessions(APIRequest): + name = "sessions" + title = "List sessions" + response_model = list[SessionModel] + + async def handle( + self, + request: SessionsRequest, + user: CurrentUser, + ) -> list[SessionModel]: + """Create or update an object.""" + + id_user = request.id_user + + if id_user != user.id and (not user.is_admin): + raise nebula.ForbiddenException() + + result = [] + async for session in Session.list(): + + if (id_user is not None) and (id_user != session.user["id"]): + continue + + if (not user.is_admin) and (id_user != session.user["id"]): + continue + + result.append(session) + + return result diff --git a/backend/api/set.py b/backend/api/set.py index 961bda0..2c5622f 100644 --- a/backend/api/set.py +++ b/backend/api/set.py @@ -60,7 +60,7 @@ def load_validators(cls) -> None: # Single operation request -class SetRequestModel(RequestModel): +class OperationModel(RequestModel): object_type: ObjectType = Field(ObjectType.ASSET, description="Object type") id: int | None = Field( None, @@ -74,8 +74,10 @@ class SetRequestModel(RequestModel): ) -class SetResponseModel(ResponseModel): +class OperationResponseModel(ResponseModel): object_type: ObjectType = Field("asset", title="Object type") + success: bool = Field(..., title="Success") + error: str | None = Field(None, title="Error message") id: int | None = Field( None, title="Object ID", @@ -86,14 +88,6 @@ class SetResponseModel(ResponseModel): # Multiple operation request -class OperationModel(SetRequestModel): - pass - - -class OperationResponseModel(SetResponseModel): - success: bool = Field(..., title="Success") - - class OperationsRequestModel(RequestModel): operations: list[OperationModel] = Field( ..., @@ -116,62 +110,38 @@ class OperationsResponseModel(ResponseModel): # -class SetRequest(APIRequest): - name = "set" - title = "Create or update an object" - response_model = SetResponseModel +async def can_modify_object(obj, user: nebula.User): + if user.is_admin: + return True - async def handle( - self, - request: SetRequestModel, - user: CurrentUser, - ) -> SetResponseModel: - """Create or update an object.""" + if isinstance(obj, nebula.Asset): + acl = user.get("can/asset_edit", False) + if not acl: + raise nebula.ForbiddenException("You are not allowed to edit assets") + elif type(acl) == list: + if obj["id_folder"] not in acl: + raise nebula.ForbiddenException( + "You are not allowed to edit assets in this folder" + ) - reload_settings = False - pool = await nebula.db.pool() - async with pool.acquire() as conn: - async with conn.transaction(): - object_class = get_object_class_by_name(request.object_type) - if request.id is None: - object = object_class(connection=conn) - object["created_by"] = user.id - object["updated_by"] = user.id - request.data.pop("id", None) - else: - object = await object_class.load(request.id, connection=conn) - object["updated_by"] = user.id - - if (password := request.data.pop("password", None)) is not None: - assert isinstance(password, str) - assert isinstance(object, nebula.User) - object.set_password(password) - - if validator := Validator.for_object(request.object_type): - try: - await validator( - object, - request.data, - connection=conn, - user=user, - ) - except nebula.RequestSettingsReload: - reload_settings = True - else: - object.update(request.data) - - await object.save() - - if isinstance(object, nebula.Item) and object["id_bin"]: - await bin_refresh([object["id_bin"]]) + elif isinstance(obj, nebula.Event): + acl = user.get("can/scheduler_edit", False) + if not acl: + raise nebula.ForbiddenException("You are not allowed to edit schedule") + elif type(acl) == list: + if obj["id_channel"] not in acl: + raise nebula.ForbiddenException( + "You are not allowed to edit schedule for this channel" + ) - if reload_settings: - await load_settings() + elif isinstance(obj, nebula.Item): + acl = user.get("can/rundown_edit", False) + if not acl: + raise nebula.ForbiddenException("You are not allowed to edit rundown") + # TODO: Check if user can edit rundown for this channel - return SetResponseModel( - id=object.id, - object_type=request.object_type, - ) + elif isinstance(obj, nebula.Event): + return class OperationsRequest(APIRequest): @@ -192,6 +162,7 @@ async def handle( affected_bins: list[int] = [] for operation in request.operations: success = True + error = None op_id = operation.id try: async with pool.acquire() as conn: @@ -210,16 +181,34 @@ async def handle( ) object["updated_by"] = user.id - if ( - password := operation.data.pop("password", None) - ) is not None: - assert isinstance( - password, str - ), "Password must be a string" - assert isinstance( - object, nebula.User - ), "Object must be a user in order to set a password" - object.set_password(password) + # + # Modyfiing users + # + + if isinstance(object, nebula.User): + if not (user.is_admin or object.id == user.id): + raise nebula.ForbiddenException( + "Unable to modify other users" + ) + + if not user.is_admin: + for key in operation.data: + if key.startswith("can/") or key.startswith("is_"): + operation.data.pop(key, None) + + password = operation.data.pop("password", None) + if password: + object.set_password(password) + + # + # ACL + # + + await can_modify_object(object, user) + + # + # Run validator + # if validator := Validator.for_object(operation.object_type): try: @@ -241,14 +230,15 @@ async def handle( ): affected_bins.append(object["id_bin"]) op_id = object.id - except Exception: - nebula.log.traceback(user=user.name) + except Exception as e: + error = str(e) success = False result.append( OperationResponseModel( id=op_id, object_type=operation.object_type, + error=error, success=success, ) ) @@ -261,3 +251,30 @@ async def handle( overall_success = all([x.success for x in result]) return OperationsResponseModel(operations=result, success=overall_success) + + +class SetRequest(APIRequest): + name = "set" + title = "Create or update an object" + response_model = OperationResponseModel + + async def handle( + self, + request: OperationModel, + user: CurrentUser, + ) -> OperationResponseModel: + """Create or update an object.""" + + operation = OperationsRequest() + result = await operation.handle( + OperationsRequestModel(operations=[request]), + user=user, + ) + + if not result.success: + raise nebula.NebulaException( + result.operations[0].error or "Unknown error", + user_name=user.name, + ) + + return result.operations[0] diff --git a/backend/nebula/__init__.py b/backend/nebula/__init__.py index 6adc2d6..a312d3a 100644 --- a/backend/nebula/__init__.py +++ b/backend/nebula/__init__.py @@ -1,4 +1,4 @@ -__version__ = "6.0.0" +__version__ = "6.0.1" __all__ = [ "config", diff --git a/backend/nebula/exceptions.py b/backend/nebula/exceptions.py index e7eac5e..9a0d88f 100644 --- a/backend/nebula/exceptions.py +++ b/backend/nebula/exceptions.py @@ -16,6 +16,7 @@ def __init__( self, detail: str | None = None, log: bool | str = False, + user_name: str | None = None, **kwargs, ) -> None: @@ -25,9 +26,9 @@ def __init__( self.detail = detail if log is True or self.log: - logger.error(f"EXCEPTION: {self.status} {self.detail}") + logger.error(f"EXCEPTION: {self.status} {self.detail}", user=user_name) elif type(log) is str: - logger.error(f"EXCEPTION: {self.status} {log}") + logger.error(f"EXCEPTION: {self.status} {log}", user=user_name) super().__init__(self.detail) diff --git a/backend/nebula/objects/item.py b/backend/nebula/objects/item.py index 735df1e..92e1711 100644 --- a/backend/nebula/objects/item.py +++ b/backend/nebula/objects/item.py @@ -54,10 +54,12 @@ def asset(self, asset: Asset) -> None: asset.id == self["id_asset"] ), f"Asset id must match item id_asset: {asset.id} != {self['id_asset']}" self._asset = asset - if not self.meta.get("mark_in") and self._asset["mark_in"]: - self["mark_in"] = self._asset["mark_in"] - if not self.meta.get("mark_out") and self._asset["mark_out"]: - self["mark_out"] = self._asset["mark_out"] + + # BIG NO-NO - if needed, do that manually!!! + # if not self.meta.get("mark_in") and self._asset["mark_in"]: + # self["mark_in"] = self._asset["mark_in"] + # if not self.meta.get("mark_out") and self._asset["mark_out"]: + # self["mark_out"] = self._asset["mark_out"] @property def duration(self) -> float: diff --git a/backend/nebula/redis.py b/backend/nebula/redis.py index 0e8c64a..a698b6c 100644 --- a/backend/nebula/redis.py +++ b/backend/nebula/redis.py @@ -78,3 +78,16 @@ async def publish(cls, message: str) -> None: if not cls.connected: await cls.connect() await cls.redis_pool.publish(cls.channel, message) + + @classmethod + async def iterate(cls, namespace: str): + """Iterate over stored keys and yield [key, payload] tuples + matching given namespace. + """ + if not cls.connected: + await cls.connect() + + async for key in cls.redis_pool.scan_iter(match=f"{namespace}-*"): + key_without_ns = key.decode("ascii").removeprefix(f"{namespace}-") + payload = await cls.redis_pool.get(key) + yield key_without_ns, payload diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 65ac38b..ab2e6b7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nebula" -version = "6.0.0" +version = "6.0.1" description = "Open source broadcast automation system" authors = ["Nebula Broadcast "] @@ -19,6 +19,9 @@ redis = "^4.5.1" rich = "^12.0.1" uvicorn = {extras = ["standard"], version = "0.20.0"} shortuuid = "^1.0.11" +email-validator = "^2.0.0.post2" +geoip2 = "^4.6.0" +user-agents = "^2.2.0" [tool.poetry.dev-dependencies] pytest = "^7.0" diff --git a/backend/server/clientinfo.py b/backend/server/clientinfo.py new file mode 100644 index 0000000..20f489a --- /dev/null +++ b/backend/server/clientinfo.py @@ -0,0 +1,114 @@ +import contextlib +import ipaddress +import os + +import geoip2 +import geoip2.database +import user_agents +from fastapi import Request +from pydantic import BaseModel, Field + + +class LocationInfo(BaseModel): + country: str = Field(None, title="Country") + subdivision: str = Field(None, title="Subdivision") + city: str = Field(None, title="City") + + +class AgentInfo(BaseModel): + platform: str = Field(None, title="Platform") + client: str = Field(None, title="Client") + device: str = Field(None, title="Device") + + +class ClientInfo(BaseModel): + ip: str + languages: list[str] = Field(default_factory=list) + location: LocationInfo | None = Field(None) + agent: AgentInfo | None = Field(None) + + +def get_real_ip(request: Request) -> str: + return request.headers.get("x-forwarded-for", request.client.host) + + +def geo_lookup(ip: str): + geoip_db_path = None # TODO + + if geoip_db_path is None: + return None + + if not os.path.exists(geoip_db_path): + return None + + with geoip2.database.Reader(geoip_db_path) as reader: + try: + response = reader.city(ip) + except geoip2.errors.AddressNotFoundError: + return None + + return LocationInfo( + country=response.country.name, + subdivision=response.subdivisions.most_specific.name, + city=response.city.name, + ) + return None + + +def is_internal_ip(ip: str) -> bool: + with contextlib.suppress(ValueError): + if ipaddress.IPv4Address(ip).is_private: + return True + + with contextlib.suppress(ValueError): + if ipaddress.IPv6Address(ip).is_private: + return True + return False + + +def get_ua_data(request) -> AgentInfo | None: + if ua_string := request.headers.get("user-agent"): + ua = user_agents.parse(ua_string) + if "mac" in ua_string.lower(): + platform = "darwin" + elif "windows" in ua_string.lower(): + platform = "windows" + elif "linux" in ua_string.lower(): + platform = "linux" + else: + platform = ua_string.lower() + return AgentInfo( + platform=platform, + client=ua.browser.family, + device=ua.device.family, + ) + return None + + +def get_prefed_languages(request: Request) -> list[str]: + languages = [] + if accept_language := request.headers.get("Accept-Language"): + try: + for lngk in accept_language.split(";"): + lang = lngk.split(",")[-1] + if len(lang) == 2: + languages.append(lang) + except Exception: + return ["en"] + else: + languages = ["en"] + return languages + + +def get_client_info(request: Request) -> ClientInfo: + ip = get_real_ip(request) + if is_internal_ip(ip): + location = None + else: + location = geo_lookup(ip) + return ClientInfo( + ip=ip, + agent=get_ua_data(request), + location=location, + languages=get_prefed_languages(request), + ) diff --git a/backend/server/dependencies.py b/backend/server/dependencies.py index ddcb632..ee34033 100644 --- a/backend/server/dependencies.py +++ b/backend/server/dependencies.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import Depends, Header, Path, Query +from fastapi import Depends, Header, Path, Query, Request import nebula from server.session import Session @@ -39,12 +39,13 @@ async def current_user_query(token: str = Query(None)) -> nebula.User: async def current_user( + request: Request, access_token: str | None = Depends(access_token), ) -> nebula.User: """Return the currently logged-in user""" if access_token is None: raise nebula.UnauthorizedException("No access token provided") - session = await Session.check(access_token, None) + session = await Session.check(access_token, request) if session is None: raise nebula.UnauthorizedException("Invalid access token") return nebula.User(meta=session.user) diff --git a/backend/server/session.py b/backend/server/session.py index e11bb17..2ccd17b 100644 --- a/backend/server/session.py +++ b/backend/server/session.py @@ -1,12 +1,23 @@ __all__ = ["Session"] import time -from typing import Any +import nebula +from typing import Any, AsyncGenerator from pydantic import BaseModel +from fastapi import Request -import nebula from nebula.common import create_hash, json_dumps, json_loads +from server.clientinfo import ClientInfo, get_client_info, get_real_ip + + +def is_local_ip(ip: str) -> bool: + return ( + ip.startswith("127.") + or ip.startswith("10.") + or ip.startswith("192.168.") + or ip.startswith("172.") + ) class SessionModel(BaseModel): @@ -14,7 +25,7 @@ class SessionModel(BaseModel): token: str created: float accessed: float - ip: str | None = None + client_info: ClientInfo | None = None class Session: @@ -22,7 +33,13 @@ class Session: ns = "session" @classmethod - async def check(cls, token: str, ip: str | None) -> SessionModel | None: + def is_expired(cls, session: SessionModel) -> bool: + return time.time() - session.accessed > cls.ttl + + @classmethod + async def check( + cls, token: str, request: Request | None = None + ) -> SessionModel | None: """Return a session corresponding to a given access token. Return None if the token is invalid. @@ -40,9 +57,21 @@ async def check(cls, token: str, ip: str | None) -> SessionModel | None: await nebula.redis.delete(cls.ns, token) return None - if ip and session.ip and session.ip != ip: - # TODO: log this? - return None + if request: + if not session.client_info: + session.client_info = get_client_info(request) + session.accessed = time.time() + await nebula.redis.set(cls.ns, token, session.json()) + else: + real_ip = get_real_ip(request) + if not is_local_ip(real_ip): + if session.client_info.ip != real_ip: + nebula.log.warning( + "Session IP mismatch. " + f"Stored: {session.client_info.ip}, current: {real_ip}" + ) + await nebula.redis.delete(cls.ns, token) + return None # Extend the session lifetime only if it's in its second half # (save update requests). @@ -56,21 +85,38 @@ async def check(cls, token: str, ip: str | None) -> SessionModel | None: return session @classmethod - async def create(cls, user: nebula.User, ip: str = None) -> SessionModel: + async def create( + cls, + user: nebula.User, + request: Request | None = None, + ) -> SessionModel: """Create a new session for a given user.""" + + client_info = get_client_info(request) if request else None + if client_info: + if user["local_network_only"] and not is_local_ip(client_info.ip): + raise nebula.UnauthorizedException( + "You can only log in from local network" + ) + token = create_hash() session = SessionModel( user=user.meta, token=token, created=time.time(), accessed=time.time(), - ip=ip, + client_info=client_info, ) await nebula.redis.set(cls.ns, token, session.json()) return session @classmethod - async def update(cls, token: str, user: nebula.User) -> None: + async def update( + cls, + token: str, + user: nebula.User, + client_info: ClientInfo | None = None, + ) -> None: """Update a session with new user data.""" data = await nebula.redis.get(cls.ns, token) if not data: @@ -80,8 +126,33 @@ async def update(cls, token: str, user: nebula.User) -> None: session = SessionModel(**json_loads(data)) session.user = user.meta session.accessed = time.time() + if client_info is not None: + session.client_info = client_info await nebula.redis.set(cls.ns, token, session.json()) @classmethod async def delete(cls, token: str) -> None: await nebula.redis.delete(cls.ns, token) + + @classmethod + async def list( + cls, user_name: str | None = None + ) -> AsyncGenerator[SessionModel, None]: + """List active sessions for all or given user + + Additionally, this function also removes expired sessions + from the database. + """ + + async for session_id, data in nebula.redis.iterate(cls.ns): + session = SessionModel(**json_loads(data)) + if cls.is_expired(session): + # nebula.log.info( + # f"Removing expired session for user" + # f"{session.user.name} {session.token}" + # ) + await nebula.redis.delete(cls.ns, session.token) + continue + + if user_name is None or session.user.name == user_name: + yield session diff --git a/frontend/src/components/DatePicker.jsx b/frontend/src/components/DatePicker.jsx new file mode 100644 index 0000000..e2abc81 --- /dev/null +++ b/frontend/src/components/DatePicker.jsx @@ -0,0 +1,86 @@ +import { useEffect } from 'react' +import RDatePicker from 'react-datepicker' +import styled from 'styled-components' +import defaultTheme from './theme' + +//import "react-datepicker/dist/react-datepicker.css" + +import './datepicker.sass' + +const PickerContainer = styled.div` + + input { + border: 0; + border-radius: ${(props) => props.theme.inputBorderRadius}; + background: ${(props) => props.theme.inputBackground}; + color: ${(props) => props.theme.colors.text}; + font-size: ${(props) => props.theme.fontSize}; + min-height: ${(props) => props.theme.inputHeight}; + max-height: ${(props) => props.theme.inputHeight}; + font-size: ${(props) => props.theme.fontSize}; + padding-left: ${(props) => props.theme.inputPadding}; + padding-right: ${(props) => props.theme.inputPadding}; + padding-top: 0; + padding-bottom: 0; + min-width: 200px; + + &:-webkit-autofill, + &:-webkit-autofill:focus { + transition: background-color 600000s 0s, color 600000s 0s; + } + + &:focus { + outline: 1px solid ${(props) => props.theme.colors.cyan}; + } + + &:hover { + color: ${(props) => props.theme.colors.text}; + } + + &:invalid, + &.error { + outline: 1px solid ${(props) => props.theme.colors.red} !important; + } + + &:read-only { + font-style: italic; + } +` +PickerContainer.defaultProps = { + theme: defaultTheme, +} + +const formatDate = (date) => { + const year = date.getFullYear() + const month = ('0' + (date.getMonth() + 1)).slice(-2) + const day = ('0' + date.getDate()).slice(-2) + return `${year}-${month}-${day}` +} + +const DatePicker = ({ value, onChange }) => { + useEffect(() => { + if (!value) { + const newValue = formatDate(new Date()) + console.log('new value', newValue) + onChange(newValue) + } + }, [value]) + + const onSelectDate = (date) => { + onChange(formatDate(date)) + } + + return ( + + + + ) +} + +export default DatePicker diff --git a/frontend/src/components/Video.jsx b/frontend/src/components/Video.jsx index 1cb30af..f06203d 100644 --- a/frontend/src/components/Video.jsx +++ b/frontend/src/components/Video.jsx @@ -170,6 +170,9 @@ const Video = ({ src, style, showMarks, marks = {}, setMarks = () => {} }) => { useEffect(() => { const handleKeyDown = (event) => { if (['INPUT', 'TEXTAREA'].includes(event.target.tagName)) return + //abort if control or alt key pressed + if (event.ctrlKey || event.altKey) return + if (event.key === 'i' || event.key === 'e') { event.preventDefault() onMarkIn() diff --git a/frontend/src/components/index.jsx b/frontend/src/components/index.jsx index 538a0ad..62e4771 100644 --- a/frontend/src/components/index.jsx +++ b/frontend/src/components/index.jsx @@ -18,6 +18,7 @@ import InputSwitch from './InputSwitch' import TextArea from './TextArea' import Button from './Button' +import DatePicker from './DatePicker' import { DateTime, Timestamp } from './fields' import { Form, FormRow } from './form' @@ -25,6 +26,7 @@ import { Navbar, Spacer, ToolbarSeparator, DialogButtons } from './layout' export { Button, + DatePicker, DateTime, Dialog, DialogButtons, diff --git a/frontend/src/index.sass b/frontend/src/index.sass index c22d088..a8c0d6d 100644 --- a/frontend/src/index.sass +++ b/frontend/src/index.sass @@ -2,7 +2,7 @@ :root - --font-family: 'Noto Sans' + --font-family: 'Noto Sans', Roboto, Arial, sans-serif --color-surface-01: #19161f --color-surface-02: #1f1e26 --color-surface-03: #24202e diff --git a/frontend/src/pages/assetEditor/assetEditorNav.jsx b/frontend/src/pages/assetEditor/assetEditorNav.jsx index 5b27a2a..fc47998 100644 --- a/frontend/src/pages/assetEditor/assetEditorNav.jsx +++ b/frontend/src/pages/assetEditor/assetEditorNav.jsx @@ -98,6 +98,13 @@ const AssetEditorNav = ({ label: 'Send to...', onClick: () => setSendToVisible(true), }, + { + label: 'Reset', + disabled: assetData.status !== 1, + onClick: () => { + setMeta('status', 5, true) + }, + }, ...scopedEndpoints, ...linkOptions, ] @@ -257,7 +264,7 @@ const AssetEditorNav = ({