diff --git a/docker-compose.yml b/docker-compose.yml index 9c0d59d5..af9b53a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,8 +64,6 @@ services: logging: *default-logging env_file: - .env - environment: - - SKYD_DISK_CACHE_ENABLED=${SKYD_DISK_CACHE_ENABLED:-true} volumes: - ./docker/data/nginx/cache:/data/nginx/cache - ./docker/data/nginx/blocker:/data/nginx/blocker diff --git a/docker/nginx/conf.d/include/cors-headers b/docker/nginx/conf.d/include/cors-headers index 0f0bb328..f6a303cd 100644 --- a/docker/nginx/conf.d/include/cors-headers +++ b/docker/nginx/conf.d/include/cors-headers @@ -2,4 +2,4 @@ more_set_headers 'Access-Control-Allow-Origin: $http_origin'; more_set_headers 'Access-Control-Allow-Credentials: true'; more_set_headers 'Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS, PUT, PATCH, DELETE'; more_set_headers 'Access-Control-Allow-Headers: DNT,User-Agent,X-Requested-With,If-Modified-Since,If-None-Match,Cache-Control,Content-Type,Range,X-HTTP-Method-Override,upload-offset,upload-metadata,upload-length,tus-version,tus-resumable,tus-extension,tus-max-size,upload-concat,location,Skynet-API-Key'; -more_set_headers 'Access-Control-Expose-Headers: Content-Length,Content-Range,ETag,Skynet-File-Metadata,Skynet-Skylink,Skynet-Proof,Skynet-Portal-Api,Skynet-Server-Api,upload-offset,upload-metadata,upload-length,tus-version,tus-resumable,tus-extension,tus-max-size,upload-concat,location'; +more_set_headers 'Access-Control-Expose-Headers: Content-Length,Content-Range,ETag,Accept-Ranges,Skynet-File-Metadata,Skynet-Skylink,Skynet-Proof,Skynet-Portal-Api,Skynet-Server-Api,upload-offset,upload-metadata,upload-length,tus-version,tus-resumable,tus-extension,tus-max-size,upload-concat,location'; diff --git a/docker/nginx/conf.d/include/location-hns b/docker/nginx/conf.d/include/location-hns index 62ff9729..0ddb62d7 100644 --- a/docker/nginx/conf.d/include/location-hns +++ b/docker/nginx/conf.d/include/location-hns @@ -1,4 +1,3 @@ -include /etc/nginx/conf.d/include/proxy-buffer; include /etc/nginx/conf.d/include/proxy-pass-internal; include /etc/nginx/conf.d/include/portal-access-check; diff --git a/docker/nginx/conf.d/include/location-skylink b/docker/nginx/conf.d/include/location-skylink index da4727c7..995a6e2d 100644 --- a/docker/nginx/conf.d/include/location-skylink +++ b/docker/nginx/conf.d/include/location-skylink @@ -1,6 +1,4 @@ include /etc/nginx/conf.d/include/cors; -include /etc/nginx/conf.d/include/proxy-buffer; -include /etc/nginx/conf.d/include/proxy-cache-downloads; include /etc/nginx/conf.d/include/track-download; limit_conn downloads_by_ip 100; # ddos protection: max 100 downloads at a time @@ -9,62 +7,10 @@ limit_conn downloads_by_ip 100; # ddos protection: max 100 downloads at a time # this is important because we want only one format in cache keys and logs set_by_lua_block $skylink { return require("skynet.skylink").parse(ngx.var.skylink) } -# $skylink_v1 and $skylink_v2 variables default to the same value but in case the requested skylink was: -# a) skylink v1 - it would not matter, no additional logic is executed -# b) skylink v2 - in a lua block below we will resolve the skylink v2 into skylink v1 and update -# $skylink_v1 variable so then the proxy request to skyd can be cached in nginx (proxy_cache_key -# in proxy-cache-downloads includes $skylink_v1 as a part of the cache key) -set $skylink_v1 $skylink; -set $skylink_v2 $skylink; - -# variable for Skynet-Proof header that we need to inject -# into a response if the request was for skylink v2 -set $skynet_proof ''; - # default download rate to unlimited set $limit_rate 0; access_by_lua_block { - -- the block below only makes sense if we are using nginx cache - if not ngx.var.skyd_disk_cache_enabled then - local httpc = require("resty.http").new() - - -- detect whether requested skylink is v2 - local isBase32v2 = string.len(ngx.var.skylink) == 55 and string.sub(ngx.var.skylink, 0, 2) == "04" - local isBase64v2 = string.len(ngx.var.skylink) == 46 and string.sub(ngx.var.skylink, 0, 2) == "AQ" - - if isBase32v2 or isBase64v2 then - -- 10.10.10.10 points to sia service (alias not available when using resty-http) - local res, err = httpc:request_uri("http://10.10.10.10:9980/skynet/resolve/" .. ngx.var.skylink_v2, { - headers = { ["User-Agent"] = "Sia-Agent" } - }) - - -- print error and exit with 500 or exit with response if status is not 200 - if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.status = (err and ngx.HTTP_INTERNAL_SERVER_ERROR) or res.status - ngx.header["content-type"] = "text/plain" - ngx.say(err or res.body) - return ngx.exit(ngx.status) - end - - local json = require('cjson') - local resolve = json.decode(res.body) - ngx.var.skylink_v1 = resolve.skylink - ngx.var.skynet_proof = res.headers["Skynet-Proof"] - end - - -- check if skylink v1 is present on blocklist (compare hashes) - if require("skynet.blocklist").is_blocked(ngx.var.skylink_v1) then - return require("skynet.blocklist").exit_illegal() - end - - -- if skylink is found on nocache list then set internal nocache variable - -- to tell nginx that it should not try and cache this file (too large) - if ngx.shared.nocache:get(ngx.var.skylink_v1) then - ngx.var.nocache = "1" - end - end - if require("skynet.account").accounts_enabled() then -- check if portal is in authenticated only mode if require("skynet.account").is_access_unauthorized() then @@ -84,36 +30,10 @@ access_by_lua_block { end } -header_filter_by_lua_block { - ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain - ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain - - -- the block below only makes sense if we are using nginx cache - if not ngx.var.skyd_disk_cache_enabled then - -- not empty skynet_proof means this is a skylink v2 request - -- so we should replace the Skynet-Proof header with the one - -- we got from /skynet/resolve/ endpoint, otherwise we would - -- be serving cached empty v1 skylink Skynet-Proof header - if ngx.var.skynet_proof and ngx.var.skynet_proof ~= "" then - ngx.header["Skynet-Proof"] = ngx.var.skynet_proof - end - - -- add skylink to nocache list if it exceeds 1GB (1e+9 bytes) threshold - -- (content length can be nil for already cached files - we can ignore them) - if ngx.header["Content-Length"] and tonumber(ngx.header["Content-Length"]) > 1e+9 then - ngx.shared.nocache:set(ngx.var.skylink_v1, ngx.header["Content-Length"]) - end - end -} - limit_rate_after 512k; limit_rate $limit_rate; proxy_read_timeout 600; proxy_set_header User-Agent: Sia-Agent; -# in case the requested skylink was v2 and we already resolved it to skylink v1, we are going to pass resolved -# skylink v1 to skyd to save that extra skylink v2 lookup in skyd but in turn, in case skyd returns a redirect -# we need to rewrite the skylink v1 to skylink v2 in the location header with proxy_redirect -proxy_redirect $skylink_v1 $skylink_v2; -proxy_pass http://sia:9980/skynet/skylink/$skylink_v1$path$is_args$args; +proxy_pass http://sia:9980/skynet/skylink/$skylink$path$is_args$args; diff --git a/docker/nginx/conf.d/include/proxy-buffer b/docker/nginx/conf.d/include/proxy-buffer deleted file mode 100644 index 0fafbade..00000000 --- a/docker/nginx/conf.d/include/proxy-buffer +++ /dev/null @@ -1,5 +0,0 @@ -# if you are expecting large headers (ie. Skynet-Skyfile-Metadata), tune these values to your needs -# read more: https://www.getpagespeed.com/server-setup/nginx/tuning-proxy_buffer_size-in-nginx -proxy_buffer_size 4096k; -proxy_buffers 64 256k; -proxy_busy_buffers_size 4096k; # at least as high as proxy_buffer_size diff --git a/docker/nginx/conf.d/include/proxy-cache-downloads b/docker/nginx/conf.d/include/proxy-cache-downloads deleted file mode 100644 index 85aeeb9e..00000000 --- a/docker/nginx/conf.d/include/proxy-cache-downloads +++ /dev/null @@ -1,21 +0,0 @@ -proxy_cache skynet; # cache name -proxy_cache_key $skylink_v1$path$arg_format$arg_attachment$arg_start$arg_end$http_range; # unique cache key -proxy_cache_min_uses 3; # cache after 3 uses -proxy_cache_valid 200 206 307 308 48h; # keep 200, 206, 307 and 308 responses valid for up to 2 days -add_header X-Proxy-Cache $upstream_cache_status; # add response header to indicate cache hits and misses - -# map skyd env variable value to "1" for true and "0" for false (expected by proxy_no_cache) -set_by_lua_block $skyd_disk_cache_enabled { - return os.getenv("SKYD_DISK_CACHE_ENABLED") == "true" and "1" or "0" -} - -# bypass - this will bypass cache hit on request (status BYPASS) -# but still stores file in cache if cache conditions are met -proxy_cache_bypass $cookie_nocache $arg_nocache $skyd_disk_cache_enabled; - -# no cache - this will ignore cache on request (status MISS) -# and does not store file in cache under no condition -set_if_empty $nocache "0"; - -# disable cache when nocache is set or skyd cache is enabled -proxy_no_cache $nocache $skyd_disk_cache_enabled; diff --git a/docker/nginx/conf.d/server.local.conf b/docker/nginx/conf.d/server.local.conf deleted file mode 100644 index 8a487a53..00000000 --- a/docker/nginx/conf.d/server.local.conf +++ /dev/null @@ -1,9 +0,0 @@ -server { - # local server - do not expose this port externally - listen 8000; - - # secure traffic by limiting to only local networks - include /etc/nginx/conf.d/include/local-network-only; - - include /etc/nginx/conf.d/server/server.local; -} diff --git a/docker/nginx/conf.d/server/server.api b/docker/nginx/conf.d/server/server.api index 24e8509d..f681cca8 100644 --- a/docker/nginx/conf.d/server/server.api +++ b/docker/nginx/conf.d/server/server.api @@ -47,7 +47,9 @@ location /skynet/portal/blocklist { proxy_cache skynet; proxy_cache_valid 200 204 15m; # cache portal blocklist for 15 minutes - proxy_pass http://blocker:4000/blocklist; + + # 10.10.10.110 points to blocker service + proxy_pass http://10.10.10.110:4000/blocklist; } location /skynet/portals { @@ -355,7 +357,6 @@ location ~ "^/file/(([a-zA-Z0-9-_]{46}|[a-z0-9]{55})(/.*)?)$" { location /skynet/trustless/basesector { include /etc/nginx/conf.d/include/cors; - include /etc/nginx/conf.d/include/proxy-buffer; include /etc/nginx/conf.d/include/track-download; limit_conn downloads_by_ip 100; # ddos protection: max 100 downloads at a time diff --git a/docker/nginx/conf.d/server/server.dnslink b/docker/nginx/conf.d/server/server.dnslink index 32e454cc..cf385a1d 100644 --- a/docker/nginx/conf.d/server/server.dnslink +++ b/docker/nginx/conf.d/server/server.dnslink @@ -38,8 +38,6 @@ location / { end ngx.var.skylink = require("skynet.skylink").parse(ngx.var.skylink) - ngx.var.skylink_v1 = ngx.var.skylink - ngx.var.skylink_v2 = ngx.var.skylink } include /etc/nginx/conf.d/include/location-skylink; diff --git a/docker/nginx/conf.d/server/server.local b/docker/nginx/conf.d/server/server.local deleted file mode 100644 index 3a8ac118..00000000 --- a/docker/nginx/conf.d/server/server.local +++ /dev/null @@ -1,37 +0,0 @@ -include /etc/nginx/conf.d/include/init-optional-variables; - -location /skynet/blocklist { - client_max_body_size 10m; # increase max body size to account for large lists - client_body_buffer_size 10m; # force whole body to memory so we can read it - - content_by_lua_block { - local httpc = require("resty.http").new() - - ngx.req.read_body() -- ensure the post body data is read before using get_body_data - - -- proxy blocklist update request - -- 10.10.10.10 points to sia service (alias not available when using resty-http) - local res, err = httpc:request_uri("http://10.10.10.10:9980/skynet/blocklist", { - method = "POST", - body = ngx.req.get_body_data(), - headers = { - ["Content-Type"] = "application/x-www-form-urlencoded", - ["Authorization"] = require("skynet.utils").authorization_header(), - ["User-Agent"] = "Sia-Agent", - } - }) - - -- print error and exit with 500 or exit with response if status is not 204 - if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then - ngx.status = (err and ngx.HTTP_INTERNAL_SERVER_ERROR) or res.status - ngx.header["content-type"] = "text/plain" - ngx.say(err or res.body) - return ngx.exit(ngx.status) - end - - require("skynet.blocklist").reload() - - ngx.status = ngx.HTTP_NO_CONTENT - return ngx.exit(ngx.status) - } -} diff --git a/docker/nginx/libs/skynet/account.lua b/docker/nginx/libs/skynet/account.lua index aa8b4414..d31ec57e 100644 --- a/docker/nginx/libs/skynet/account.lua +++ b/docker/nginx/libs/skynet/account.lua @@ -72,13 +72,13 @@ function _M.get_account_limits() local httpc = require("resty.http").new() -- 10.10.10.70 points to accounts service (alias not available when using resty-http) - local res, err = httpc:request_uri("http://10.10.10.70:3000/user/limits", { + local res, err = httpc:request_uri("http://10.10.10.70:3000/user/limits?unit=byte", { headers = auth_headers, }) -- fail gracefully in case /user/limits failed if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed accounts service request /user/limits: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + ngx.log(ngx.ERR, "Failed accounts service request /user/limits?unit=byte: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) ngx.var.account_limits = cjson.encode(anon_limits) elseif res and res.status == ngx.HTTP_OK then ngx.var.account_limits = res.body diff --git a/docker/nginx/libs/skynet/blocklist.lua b/docker/nginx/libs/skynet/blocklist.lua deleted file mode 100644 index 29f53032..00000000 --- a/docker/nginx/libs/skynet/blocklist.lua +++ /dev/null @@ -1,66 +0,0 @@ -local _M = {} - -function _M.reload() - local httpc = require("resty.http").new() - - -- fetch blocklist records (all blocked skylink hashes) - -- 10.10.10.10 points to sia service (alias not available when using resty-http) - local res, err = httpc:request_uri("http://10.10.10.10:9980/skynet/blocklist", { - headers = { - ["User-Agent"] = "Sia-Agent", - } - }) - - -- fail whole request in case this request failed, we want to make sure - -- the blocklist is pre cached before serving first skylink - if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed skyd service request /skynet/blocklist: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) - ngx.status = (err and ngx.HTTP_INTERNAL_SERVER_ERROR) or res.status - ngx.header["content-type"] = "text/plain" - ngx.say(err or res.body) - return ngx.exit(ngx.status) - elseif res and res.status == ngx.HTTP_OK then - local json = require('cjson') - local data = json.decode(res.body) - - -- mark all existing entries as expired - ngx.shared.blocklist:flush_all() - - -- check if blocklist is table (it is null when empty) - if type(data.blocklist) == "table" then - -- set all cache entries one by one (resets expiration) - for i, hash in ipairs(data.blocklist) do - ngx.shared.blocklist:set(hash, true) - end - end - - -- ensure that init flag is persisted - ngx.shared.blocklist:set("__init", true) - - -- remove all leftover expired entries - ngx.shared.blocklist:flush_expired() - end -end - -function _M.is_blocked(skylink) - -- make sure that blocklist has been preloaded - if not ngx.shared.blocklist:get("__init") then _M.reload() end - - -- hash skylink before comparing it with blocklist - local hash = require("skynet.skylink").hash(skylink) - - -- we need to use get_stale because we are expiring previous - -- entries when the blocklist is reloading and we still want - -- to block them until the reloading is finished - return ngx.shared.blocklist:get_stale(hash) == true -end - --- exit with 416 illegal content status code -function _M.exit_illegal() - ngx.status = ngx.HTTP_ILLEGAL - ngx.header["content-type"] = "text/plain" - ngx.say("Unavailable For Legal Reasons") - return ngx.exit(ngx.status) -end - -return _M diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 7f43d192..95bb61ac 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -31,7 +31,6 @@ env SERVER_DOMAIN; env PORTAL_MODULES; env ACCOUNTS_LIMIT_ACCESS; env SIA_API_PASSWORD; -env SKYD_DISK_CACHE_ENABLED; events { worker_connections 8192; @@ -75,20 +74,10 @@ http { # proxy cache definition proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=skynet:10m max_size=50g min_free=100g inactive=48h use_temp_path=off; - # create a shared blocklist dictionary with size of 30 megabytes - # estimated capacity of 1 megabyte dictionary is 3500 blocklist entries - # that gives us capacity of around 100k entries in 30 megabyte dictionary - lua_shared_dict blocklist 30m; - - # create a shared dictionary to fill with skylinks that should not - # be cached due to the large size or some other reasons - lua_shared_dict nocache 10m; - # this runs before forking out nginx worker processes init_by_lua_block { require "cjson" require "resty.http" - require "skynet.blocklist" require "skynet.skylink" require "skynet.utils" } diff --git a/packages/dashboard-v2/src/components/AvatarUploader/AvatarUploader.js b/packages/dashboard-v2/src/components/AvatarUploader/AvatarUploader.js new file mode 100644 index 00000000..9f5bbc82 --- /dev/null +++ b/packages/dashboard-v2/src/components/AvatarUploader/AvatarUploader.js @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; + +import { useUser } from "../../contexts/user"; +import { SimpleUploadIcon } from "../Icons"; + +const AVATAR_PLACEHOLDER = "/images/avatar-placeholder.svg"; + +export const AvatarUploader = (props) => { + const { user } = useUser(); + const [imageUrl, setImageUrl] = useState(AVATAR_PLACEHOLDER); + + useEffect(() => { + setImageUrl(user.avatarUrl ?? AVATAR_PLACEHOLDER); + }, [user]); + + return ( +
+
+ +
+
+ + {/* TODO: actual uploading */} +
+
+ ); +}; diff --git a/packages/dashboard-v2/src/components/AvatarUploader/index.js b/packages/dashboard-v2/src/components/AvatarUploader/index.js new file mode 100644 index 00000000..74358cdc --- /dev/null +++ b/packages/dashboard-v2/src/components/AvatarUploader/index.js @@ -0,0 +1 @@ +export * from "./AvatarUploader"; diff --git a/packages/dashboard-v2/src/components/Button/Button.js b/packages/dashboard-v2/src/components/Button/Button.js index 95ecbede..40ec1ad9 100644 --- a/packages/dashboard-v2/src/components/Button/Button.js +++ b/packages/dashboard-v2/src/components/Button/Button.js @@ -6,7 +6,7 @@ import styled from "styled-components"; */ export const Button = styled.button.attrs(({ $primary }) => ({ type: "button", - className: `px-6 py-3 rounded-full font-sans uppercase text-xs tracking-wide text-palette-600 transition-[filter] hover:brightness-90 + className: `px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide text-palette-600 transition-[filter] hover:brightness-90 ${$primary ? "bg-primary" : "bg-white border-2 border-black"}`, }))``; Button.propTypes = { diff --git a/packages/dashboard-v2/src/components/CopyButton.js b/packages/dashboard-v2/src/components/CopyButton.js new file mode 100644 index 00000000..479352d2 --- /dev/null +++ b/packages/dashboard-v2/src/components/CopyButton.js @@ -0,0 +1,50 @@ +import { useCallback, useRef, useState } from "react"; +import copy from "copy-text-to-clipboard"; +import styled from "styled-components"; +import { useClickAway } from "react-use"; + +import { CopyIcon } from "./Icons"; + +const Button = styled.button.attrs({ + className: "relative inline-flex items-center hover:text-primary", +})``; + +const TooltipContainer = styled.div.attrs(({ $visible }) => ({ + className: `absolute left-full top-1/2 z-10 + bg-white rounded border border-primary/30 shadow-md + pointer-events-none transition-opacity duration-150 ease-in-out + ${$visible ? "opacity-100" : "opacity-0"}`, +}))` + transform: translateY(-50%); +`; + +const TooltipContent = styled.div.attrs({ + className: "bg-primary-light/10 text-palette-600 py-2 px-4 ", +})``; + +export const CopyButton = ({ value, className }) => { + const containerRef = useRef(); + const [copied, setCopied] = useState(false); + const [timer, setTimer] = useState(null); + + const handleCopy = useCallback(() => { + clearTimeout(timer); + copy(value); + setCopied(true); + + setTimer(setTimeout(() => setCopied(false), 1500)); + }, [value, timer]); + + useClickAway(containerRef, () => setCopied(false)); + + return ( +
+ + + Copied to clipboard + +
+ ); +}; diff --git a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js index 461a9b7e..86cbad5f 100644 --- a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js +++ b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenu.js @@ -30,7 +30,7 @@ const TriggerIcon = styled(ChevronDownIcon).attrs({ `; const Flyout = styled.div.attrs(({ open }) => ({ - className: `absolute top-full right-0 p-0 + className: `absolute top-full right-0 p-0 z-10 border rounded border-palette-100 bg-white shadow-md shadow-palette-200/50 ${open ? "visible" : "invisible"}`, diff --git a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js index 45a86483..426501c4 100644 --- a/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js +++ b/packages/dashboard-v2/src/components/DropdownMenu/DropdownMenuLink.js @@ -3,18 +3,16 @@ import PropTypes from "prop-types"; const DropdownLink = styled.a.attrs({ className: `m-0 border-t border-palette-200/50 h-[60px] - whitespace-nowrap transition-colors text-current + whitespace-nowrap transition-colors hover:bg-palette-100/50 flex items-center pr-8 pl-6 py-4 gap-4 first:border-0`, })``; export const DropdownMenuLink = ({ active, icon: Icon, label, ...props }) => ( - <> - - {Icon ? : null} - {label} - - + + {Icon ? : null} + {label} + ); DropdownMenuLink.propTypes = { diff --git a/packages/dashboard-v2/src/components/FileList/FileList.js b/packages/dashboard-v2/src/components/FileList/FileList.js new file mode 100644 index 00000000..6342b970 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/FileList.js @@ -0,0 +1,74 @@ +import * as React from "react"; +import useSWR from "swr"; +import { useMedia } from "react-use"; + +import theme from "../../lib/theme"; + +import { ContainerLoadingIndicator } from "../LoadingIndicator"; +import { Select, SelectOption } from "../Select"; +import { Switch } from "../Switch"; +import { TextInputIcon } from "../TextInputIcon/TextInputIcon"; +import { SearchIcon } from "../Icons"; + +import FileTable from "./FileTable"; +import useFormattedFilesData from "./useFormattedFilesData"; + +const FileList = ({ type }) => { + const isMediumScreenOrLarger = useMedia(`(min-width: ${theme.screens.md})`); + const { data, error } = useSWR(`user/${type}?pageSize=10`); + const items = useFormattedFilesData(data?.items || []); + + const setFilter = (name, value) => console.log("filter", name, "set to", value); + + if (!items.length) { + return ( +
+ {/* TODO: proper error message */} + {!data && !error && } + {!data && error &&

An error occurred while loading this data.

} + {data &&

No {type} found.

} +
+ ); + } + + return ( +
+
+ } + onChange={console.log.bind(console)} + /> +
+ setFilter("showSmallFiles", value)} className="mr-8"> + + Show small files + + +
+ File type: + +
+
+ Sort: + +
+
+
+ {/* TODO: mobile view (it's not tabular) */} + {isMediumScreenOrLarger ? : "Mobile view"} +
+ ); +}; + +export default FileList; diff --git a/packages/dashboard-v2/src/components/FileList/FileTable.js b/packages/dashboard-v2/src/components/FileList/FileTable.js new file mode 100644 index 00000000..90c9600f --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/FileTable.js @@ -0,0 +1,111 @@ +import { CogIcon, ShareIcon } from "../Icons"; +import { PopoverMenu } from "../PopoverMenu/PopoverMenu"; +import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "../Table"; +import { CopyButton } from "../CopyButton"; + +const buildShareMenu = (item) => { + return [ + { + label: "Facebook", + callback: () => { + console.info("share to Facebook", item); + }, + }, + { + label: "Twitter", + callback: () => { + console.info("share to Twitter", item); + }, + }, + { + label: "Discord", + callback: () => { + console.info("share to Discord", item); + }, + }, + ]; +}; + +const buildOptionsMenu = (item) => { + return [ + { + label: "Preview", + callback: () => { + console.info("preview", item); + }, + }, + { + label: "Download", + callback: () => { + console.info("download", item); + }, + }, + { + label: "Unpin", + callback: () => { + console.info("unpin", item); + }, + }, + { + label: "Report", + callback: () => { + console.info("report", item); + }, + }, + ]; +}; + +export default function FileTable({ items }) { + return ( + + + + Name + Type + + Size + + Uploaded + Skylink + Activity + + + + {items.map((item) => { + const { id, name, type, size, date, skylink } = item; + + return ( + + {name} + {type} + + {size} + + {date} + +
+ + {skylink} +
+
+ +
+ + + + + + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/packages/dashboard-v2/src/components/FileList/index.js b/packages/dashboard-v2/src/components/FileList/index.js new file mode 100644 index 00000000..93296508 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/index.js @@ -0,0 +1 @@ +export * from "./FileList"; diff --git a/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js new file mode 100644 index 00000000..82d95090 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import prettyBytes from "pretty-bytes"; +import dayjs from "dayjs"; + +const parseFileName = (fileName) => { + const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity; + + return [fileName.substr(0, lastDotIndex), fileName.substr(lastDotIndex)]; +}; + +const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => { + const [name, type] = parseFileName(rawFileName); + const date = dayjs(uploadedOn || downloadedOn).format("MM/DD/YYYY; HH:MM"); + + return { + ...rest, + date, + size: prettyBytes(size), + type, + name, + }; +}; + +const useFormattedFilesData = (items) => useMemo(() => items.map(formatItem), [items]); + +export default useFormattedFilesData; diff --git a/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js b/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js new file mode 100644 index 00000000..c3ceb9ac --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const CopyIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js b/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js new file mode 100644 index 00000000..f551dea6 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const SearchIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js b/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js new file mode 100644 index 00000000..f25afeaf --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js @@ -0,0 +1,18 @@ +import { withIconProps } from "../withIconProps"; + +export const ShareIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/SimpleUploadIcon.js b/packages/dashboard-v2/src/components/Icons/icons/SimpleUploadIcon.js new file mode 100644 index 00000000..0406f012 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/SimpleUploadIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const SimpleUploadIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/index.js b/packages/dashboard-v2/src/components/Icons/index.js index 41552e34..611acef4 100644 --- a/packages/dashboard-v2/src/components/Icons/index.js +++ b/packages/dashboard-v2/src/components/Icons/index.js @@ -9,3 +9,7 @@ export * from "./icons/CircledErrorIcon"; export * from "./icons/CircledProgressIcon"; export * from "./icons/CircledArrowUpIcon"; export * from "./icons/PlusIcon"; +export * from "./icons/SearchIcon"; +export * from "./icons/CopyIcon"; +export * from "./icons/ShareIcon"; +export * from "./icons/SimpleUploadIcon"; diff --git a/packages/dashboard-v2/src/components/Icons/withIconProps.js b/packages/dashboard-v2/src/components/Icons/withIconProps.js index d4267318..5da47331 100644 --- a/packages/dashboard-v2/src/components/Icons/withIconProps.js +++ b/packages/dashboard-v2/src/components/Icons/withIconProps.js @@ -4,7 +4,7 @@ const propTypes = { /** * Size of the icon's bounding box. */ - size: PropTypes.number, + size: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }; const defaultProps = { diff --git a/packages/dashboard-v2/src/components/NavBar/NavBar.js b/packages/dashboard-v2/src/components/NavBar/NavBar.js index 2b53f2be..1db72dda 100644 --- a/packages/dashboard-v2/src/components/NavBar/NavBar.js +++ b/packages/dashboard-v2/src/components/NavBar/NavBar.js @@ -9,7 +9,7 @@ import { PageContainer } from "../PageContainer"; import { NavBarLink, NavBarSection } from "."; const NavBarContainer = styled.div.attrs({ - className: `grid sticky top-0 bg-white`, + className: `grid sticky top-0 bg-white z-10 shadow-sm`, })``; const NavBarBody = styled.nav.attrs({ @@ -68,8 +68,21 @@ export const NavBar = () => ( - - + + diff --git a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js index e69de29b..1826cd92 100644 --- a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js +++ b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js @@ -0,0 +1,90 @@ +import { Children, cloneElement, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import { useClickAway } from "react-use"; +import styled, { css, keyframes } from "styled-components"; + +const dropDown = keyframes` + 0% { + transform: scaleY(0); + } + 80% { + transform: scaleY(1.1); + } + 100% { + transform: scaleY(1); + } +`; + +const Container = styled.div.attrs({ className: "relative inline-flex" })``; + +const Flyout = styled.ul.attrs({ + className: `absolute right-0 z-10 py-2 + border rounded bg-white + overflow-hidden pointer-events-none + shadow-md shadow-palette-200/50 + pointer-events-auto h-auto overflow-visible border-primary`, +})` + top: calc(100% + 2px); + animation: ${css` + ${dropDown} 0.1s ease-in-out + `}; +`; + +const Option = styled.li.attrs({ + className: `font-sans text-xs uppercase + relative pl-3 pr-5 py-1 + text-palette-600 cursor-pointer + hover:text-primary hover:font-normal + active:text-primary active:font-normal + + before:content-[initial] before:absolute before:left-0 before:h-3 before:w-0.5 before:bg-primary before:top-1.5 + hover:before:content-['']`, +})``; + +export const PopoverMenu = ({ options, children, openClassName, ...props }) => { + const containerRef = useRef(); + const [open, setOpen] = useState(false); + + useClickAway(containerRef, () => setOpen(false)); + + const handleChoice = (callback) => () => { + setOpen(false); + callback(); + }; + + return ( + + {Children.only( + cloneElement(children, { + onClick: () => setOpen((open) => !open), + className: `${children.props.className ?? ""} ${open ? openClassName : ""}`, + }) + )} + {open && ( + + {options.map(({ label, callback }) => ( + + ))} + + )} + + ); +}; + +PopoverMenu.propTypes = { + /** + * Accepts a single child node that will become a menu toggle. + */ + children: PropTypes.element.isRequired, + /** + * Positions in the menu + */ + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + callback: PropTypes.func.isRequired, + }) + ).isRequired, +}; diff --git a/packages/dashboard-v2/src/components/Select/Select.js b/packages/dashboard-v2/src/components/Select/Select.js index 97d3d73b..0a59a581 100644 --- a/packages/dashboard-v2/src/components/Select/Select.js +++ b/packages/dashboard-v2/src/components/Select/Select.js @@ -21,9 +21,11 @@ const dropDown = keyframes` const Container = styled.div.attrs({ className: "relative inline-flex" })``; -const Trigger = styled.button.attrs(({ placeholder }) => ({ - className: `flex items-center cursor-pointer ${placeholder ? "text-palette-300" : ""}`, -}))``; +const Trigger = styled.button.attrs(({ $placeholder }) => ({ + className: `flex items-center cursor-pointer font-bold ${$placeholder ? "text-palette-300" : ""}`, +}))` + text-transform: inherit; +`; const TriggerIcon = styled(ChevronDownIcon).attrs({ className: "transition-transform text-primary", @@ -32,13 +34,14 @@ const TriggerIcon = styled(ChevronDownIcon).attrs({ `; const Flyout = styled.ul.attrs(({ open }) => ({ - className: `absolute top-[20px] right-0 - p-0 h-0 border rounded bg-white + className: `absolute right-0 z-10 + p-0 border rounded bg-white overflow-hidden pointer-events-none shadow-md shadow-palette-200/50 ${open ? "pointer-events-auto h-auto overflow-visible border-primary" : ""} ${open ? "visible" : "invisible"}`, }))` + top: calc(100% + 2px); animation: ${({ open }) => open ? css` @@ -47,7 +50,7 @@ const Flyout = styled.ul.attrs(({ open }) => ({ : "none"}; `; -export const Select = ({ defaultValue, children, onChange, placeholder }) => { +export const Select = ({ defaultValue, children, onChange, placeholder, ...props }) => { const selectRef = useRef(); const options = useMemo(() => Children.toArray(children).filter(({ type }) => type === SelectOption), [children]); const [state, dispatch] = useSelectReducer({ defaultValue, placeholder, options }); @@ -65,8 +68,8 @@ export const Select = ({ defaultValue, children, onChange, placeholder }) => { const activeLabel = activeOption?.props?.label ?? null; return ( - - + + {activeLabel ?? placeholder} diff --git a/packages/dashboard-v2/src/components/Switch/Switch.css b/packages/dashboard-v2/src/components/Switch/Switch.css deleted file mode 100644 index 4bd07cf2..00000000 --- a/packages/dashboard-v2/src/components/Switch/Switch.css +++ /dev/null @@ -1,40 +0,0 @@ -.react-switch-checkbox { - height: 0; - width: 0; - visibility: hidden; -} - -.react-switch-label { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - width: 44px; - height: 22px; - background: white; - border-radius: 11px; - @apply border-palette-200; - border-width: 1px; - position: relative; - transition: background-color 0.2s; -} - -.react-switch-label .react-switch-button { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - border-radius: 8px; - transition: 0.2s; -} - -.react-switch-checkbox:checked + .react-switch-label .react-switch-button { - left: calc(100% - 2px); - transform: translateX(-100%); -} - -.react-switch-label:active .react-switch-button { - width: 20px; -} diff --git a/packages/dashboard-v2/src/components/Switch/Switch.js b/packages/dashboard-v2/src/components/Switch/Switch.js index 7709412b..892692be 100644 --- a/packages/dashboard-v2/src/components/Switch/Switch.js +++ b/packages/dashboard-v2/src/components/Switch/Switch.js @@ -1,37 +1,91 @@ import PropTypes from "prop-types"; -import "./Switch.css"; +import { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; +import { nanoid } from "nanoid"; + +const Container = styled.div.attrs({ + className: "inline-flex items-center gap-1 cursor-pointer select-none", +})``; + +const Checkbox = styled.input.attrs({ + type: "checkbox", + className: `h-0 w-0 hidden`, +})``; + +const Label = styled.label.attrs({ + className: "cursor-pointer inline-flex items-center gap-2", +})` + &:active .toggle-pin { + width: 20px; + } +`; + +const Toggle = styled.span.attrs({ + className: `flex flex-row items-center justify-between shrink-0 + w-[44px] h-[22px] bg-white rounded-full + border border-palette-200 relative cursor-pointer`, +})` + &:active .toggle-pin { + width: 20px; + } +`; + +const TogglePin = styled.span.attrs(({ $checked }) => ({ + className: `toggle-pin + absolute top-[2px] w-4 h-4 rounded-full + transition-[width_left] active:w-5 + + ${$checked ? "checked bg-primary" : "bg-palette-200"}`, +}))` + left: 2px; + + &.checked { + left: calc(100% - 2px); + transform: translateX(-100%); + } +`; + +export const Switch = ({ children, defaultChecked, labelClassName, onChange, ...props }) => { + const id = useMemo(nanoid, [onChange]); + const [checked, setChecked] = useState(defaultChecked); + + useEffect(() => { + onChange(checked); + }, [checked, onChange]); -/** - * Primary UI component for user interaction - */ -export const Switch = ({ isOn, handleToggle }) => { return ( - <> - - - + + setChecked(ev.target.checked)} id={id} /> + + ); }; Switch.propTypes = { /** - * Switch's current value + * Should the checkbox be checked by default? */ - isOn: PropTypes.bool, + defaultChecked: PropTypes.bool, + /** + * Element to be rendered as the switch label + */ + children: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), + /** + * Pass additional CSS classes to the `label` element. + */ + labelClassName: PropTypes.string, /** * Function to execute on change */ - handleToggle: PropTypes.func, + onChange: PropTypes.func.isRequired, }; Switch.defaultProps = { - isOn: false, + defaultChecked: false, + labelClassName: "", }; diff --git a/packages/dashboard-v2/src/components/Switch/Switch.stories.js b/packages/dashboard-v2/src/components/Switch/Switch.stories.js index 41d64f6b..8e0cba7d 100644 --- a/packages/dashboard-v2/src/components/Switch/Switch.stories.js +++ b/packages/dashboard-v2/src/components/Switch/Switch.stories.js @@ -13,10 +13,10 @@ const Template = (args) => ; export const SwitchTrue = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args SwitchTrue.args = { - isOn: true, + defaultChecked: true, }; export const SwitchFalse = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args SwitchFalse.args = { - isOn: false, + defaultChecked: false, }; diff --git a/packages/dashboard-v2/src/components/Table/Table.js b/packages/dashboard-v2/src/components/Table/Table.js index 8d55e119..e741da20 100644 --- a/packages/dashboard-v2/src/components/Table/Table.js +++ b/packages/dashboard-v2/src/components/Table/Table.js @@ -1,7 +1,7 @@ import styled from "styled-components"; const Container = styled.div.attrs({ - className: "p-1 max-w-full overflow-x-auto", + className: "p-1 max-w-full", })``; const StyledTable = styled.table.attrs({ diff --git a/packages/dashboard-v2/src/components/Table/TableHeadCell.js b/packages/dashboard-v2/src/components/Table/TableHeadCell.js index aeb65670..f16530f0 100644 --- a/packages/dashboard-v2/src/components/Table/TableHeadCell.js +++ b/packages/dashboard-v2/src/components/Table/TableHeadCell.js @@ -4,7 +4,8 @@ import styled from "styled-components"; * Accepts all HMTL attributes a `` element does. */ export const TableHeadCell = styled.th.attrs({ - className: `px-6 py-2.5 truncate h-tableRow + className: `first:pl-6 last:pr-6 px-2 py-4 + truncate h-tableRow text-palette-600 font-sans font-light text-xs first:rounded-l-sm last:rounded-r-sm`, })` diff --git a/packages/dashboard-v2/src/components/Tabs/Tabs.js b/packages/dashboard-v2/src/components/Tabs/Tabs.js index 4bf20ccf..eae51a65 100644 --- a/packages/dashboard-v2/src/components/Tabs/Tabs.js +++ b/packages/dashboard-v2/src/components/Tabs/Tabs.js @@ -34,7 +34,11 @@ const Body = styled.div.attrs({ className: "grow min-h-0" })``; export const Tabs = ({ defaultTab, children, variant }) => { const getTabId = usePrefixedTabIds(); const { tabs, panels, tabsRefs } = useTabsChildren(children, getTabId); - const defaultTabId = useMemo(() => getTabId(defaultTab || tabs[0].props.id), [getTabId, defaultTab, tabs]); + const defaultTabId = useMemo(() => { + const requestedTabIsPresent = tabs.find(({ props }) => props.id === defaultTab); + + return getTabId(requestedTabIsPresent ? defaultTab : tabs[0].props.id); + }, [getTabId, defaultTab, tabs]); const [activeTabId, setActiveTabId] = useState(defaultTabId); const [activeTabRef, setActiveTabRef] = useState(tabsRefs[activeTabId]); const isActive = (id) => id === activeTabId; diff --git a/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js b/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js index 9fa7f670..6b9531b4 100644 --- a/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js +++ b/packages/dashboard-v2/src/components/TextInputBasic/TextInputBasic.js @@ -1,18 +1,20 @@ +import { nanoid } from "nanoid"; import PropTypes from "prop-types"; +import { useMemo } from "react"; + +export const TextInputBasic = ({ label, placeholder, ...props }) => { + const id = useMemo(() => `input-${nanoid()}`, []); -/** - * Primary UI component for user interaction - */ -export const TextInputBasic = ({ label, placeholder }) => { return ( -
-

{label}

+
+
); diff --git a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js index 892da996..506ee1fc 100644 --- a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js +++ b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js @@ -1,20 +1,45 @@ import PropTypes from "prop-types"; +import cn from "classnames"; +import { useEffect, useRef, useState } from "react"; +import { PlusIcon } from "../Icons"; + +export const TextInputIcon = ({ className, icon, placeholder, onChange }) => { + const inputRef = useRef(); + const [focused, setFocused] = useState(false); + const [value, setValue] = useState(""); + + useEffect(() => { + onChange(value); + }, [value, onChange]); -/** - * Primary UI component for user interaction - */ -export const TextInputIcon = ({ icon, position, placeholder }) => { return ( -
- {position === "left" ?
{icon}
: null} - +
{icon}
+ setFocused(true)} + onBlur={() => setFocused(false)} + onChange={(event) => setValue(event.target.value)} + placeholder={placeholder} + className="focus:outline-none bg-transparent placeholder:text-palette-400" /> - {position === "right" ?
{icon}
: null} + {value && ( + setValue("")} + /> + )}
); }; @@ -23,13 +48,13 @@ TextInputIcon.propTypes = { /** * Icon to place in text input */ - icon: PropTypes.element, - /** - * Side to place icon - */ - position: PropTypes.oneOf(["left", "right"]), + icon: PropTypes.element.isRequired, /** * Input placeholder */ placeholder: PropTypes.string, + /** + * Function to be called whenever value changes + */ + onChange: PropTypes.func.isRequired, }; diff --git a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js index 676ca9cf..521b90df 100644 --- a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js +++ b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js @@ -1,5 +1,6 @@ import { TextInputIcon } from "./TextInputIcon"; -import { CogIcon } from "../Icons"; +import { SearchIcon } from "../Icons"; +import { Panel } from "../Panel"; // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export export default { @@ -9,19 +10,21 @@ export default { }; // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Template = (args) => ; +const Template = (args) => ( + + + +); export const IconLeft = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args IconLeft.args = { - icon: , - position: "left", + icon: , placeholder: "Search", }; export const IconRight = Template.bind({}); IconRight.args = { - icon: , - position: "right", + icon: , placeholder: "Search", }; diff --git a/packages/dashboard-v2/src/layouts/UserSettingsLayout.js b/packages/dashboard-v2/src/layouts/UserSettingsLayout.js new file mode 100644 index 00000000..9fda613d --- /dev/null +++ b/packages/dashboard-v2/src/layouts/UserSettingsLayout.js @@ -0,0 +1,87 @@ +import * as React from "react"; +import { Link } from "gatsby"; +import styled from "styled-components"; +import { SWRConfig } from "swr"; + +import { authenticatedOnly } from "../lib/swrConfig"; + +import { PageContainer } from "../components/PageContainer"; +import { NavBar } from "../components/Navbar"; +import { Footer } from "../components/Footer"; +import { UserProvider, useUser } from "../contexts/user"; +import { ContainerLoadingIndicator } from "../components/LoadingIndicator"; + +const Wrapper = styled.div.attrs({ + className: "min-h-screen overflow-hidden", +})` + background-image: url(/images/dashboard-bg.svg); + background-position: center -280px; + background-repeat: no-repeat; +`; + +const Layout = ({ children }) => { + const { user } = useUser(); + + // Prevent from flashing the dashboard screen to unauthenticated users. + return ( + + {!user && ( +
+ +
+ )} + {user && <>{children}} +
+ ); +}; + +const Sidebar = () => ( + +); + +const SidebarLink = styled(Link).attrs({ + className: `h-12 py-3 px-6 h-full w-full flex + border-l-2 border-l-palette-200 + border-b border-b-palette-100 last:border-b-transparent`, +})``; + +const Content = styled.main.attrs({ + className: "relative bg-white rounded px-6 py-6 sm:px-16 sm:py-14 mt-6 lg:mt-0 bg-none xl:bg-corner-circle", +})` + background-repeat: no-repeat; +`; + +const UserSettingsLayout = ({ children }) => ( + + + + + +
Settings
+
+ + {children} +
+
+