Create monorepo from known-good production state
This commit is contained in:
commit
c034824338
651 changed files with 120469 additions and 0 deletions
8
.github/FUNDING.yml
vendored
Normal file
8
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: grav
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
custom: # Replace with a single custom sponsorship URL
|
||||
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Secrets and local environment configuration
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# JavaScript dependencies and build output
|
||||
**/node_modules/
|
||||
**/.next/
|
||||
**/dist/
|
||||
**/build/
|
||||
**/coverage/
|
||||
**/.pnp
|
||||
**/.pnp.js
|
||||
|
||||
# Python environments and generated files
|
||||
**/.venv/
|
||||
**/venv/
|
||||
**/ENV/
|
||||
**/__pycache__/
|
||||
**/*.py[cod]
|
||||
|
||||
# BGBye downloaded models and runtime caches
|
||||
bgbye/.cache/
|
||||
bgbye/.u2net/
|
||||
bgbye/.ormbg/
|
||||
bgbye/.models/
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Local backups and incident copies
|
||||
*.bak
|
||||
*.bak.*
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# Editor and operating-system files
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Local operational data containing supporter records
|
||||
app/data/kofi.csv
|
||||
|
||||
# Obsolete local source archive
|
||||
app/components/account.zip
|
||||
|
||||
# Accidental root lockfile; applications have their own lockfiles
|
||||
/package-lock.json
|
||||
78
.htaccess
Normal file
78
.htaccess
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<IfModule mod_rewrite.c>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
## Begin RewriteBase
|
||||
# If you are getting 500 or 404 errors on subpages, you may have to uncomment the RewriteBase entry
|
||||
# You should change the '/' to your appropriate subfolder. For example if you have
|
||||
# your Grav install at the root of your site '/' should work, else it might be something
|
||||
# along the lines of: RewriteBase /<your_sub_folder>
|
||||
##
|
||||
|
||||
# RewriteBase /
|
||||
|
||||
## End - RewriteBase
|
||||
|
||||
## Begin - X-Forwarded-Proto
|
||||
# In some hosted or load balanced environments, SSL negotiation happens upstream.
|
||||
# In order for Grav to recognize the connection as secure, you need to uncomment
|
||||
# the following lines.
|
||||
#
|
||||
# RewriteCond %{HTTP:X-Forwarded-Proto} https
|
||||
# RewriteRule .* - [E=HTTPS:on]
|
||||
#
|
||||
## End - X-Forwarded-Proto
|
||||
|
||||
## Begin - Exploits
|
||||
# If you experience problems on your site block out the operations listed below
|
||||
# This attempts to block the most common type of exploit `attempts` to Grav
|
||||
#
|
||||
# Block out any script trying to use twig tags in URL.
|
||||
RewriteCond %{REQUEST_URI} ({{|}}|{%|%}) [OR]
|
||||
RewriteCond %{QUERY_STRING} ({{|}}|{%25|%25}) [OR]
|
||||
# Block out any script trying to base64_encode data within the URL.
|
||||
RewriteCond %{QUERY_STRING} base64_encode[^(]*\([^)]*\) [OR]
|
||||
# Block out any script that includes a <script> tag in URL.
|
||||
RewriteCond %{QUERY_STRING} (<|%3C)([^s]*s)+cript.*(>|%3E) [NC,OR]
|
||||
# Block out any script trying to set a PHP GLOBALS variable via URL.
|
||||
RewriteCond %{QUERY_STRING} GLOBALS(=|\[|\%[0-9A-Z]{0,2}) [OR]
|
||||
# Block out any script trying to modify a _REQUEST variable via URL.
|
||||
RewriteCond %{QUERY_STRING} _REQUEST(=|\[|\%[0-9A-Z]{0,2})
|
||||
# Return 403 Forbidden header and show the content of the root homepage
|
||||
RewriteRule .* index.php [F]
|
||||
#
|
||||
## End - Exploits
|
||||
|
||||
## Begin - Index
|
||||
# If the requested path and file is not /index.php and the request
|
||||
# has not already been internally rewritten to the index.php script
|
||||
RewriteCond %{REQUEST_URI} !^/index\.php
|
||||
# and the requested path and file doesn't directly match a physical file
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
# and the requested path and file doesn't directly match a physical folder
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
# internally rewrite the request to the index.php script
|
||||
RewriteRule .* index.php [L]
|
||||
## End - Index
|
||||
|
||||
## Begin - Security
|
||||
# Block all direct access for these folders
|
||||
RewriteRule ^(\.git|cache|bin|logs|backup|webserver-configs|tests)/(.*) error [F]
|
||||
# Block access to specific file types for these system folders
|
||||
RewriteRule ^(system|vendor)/(.*)\.(txt|xml|md|html|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ error [F]
|
||||
# Block access to specific file types for these user folders
|
||||
RewriteRule ^(user)/(.*)\.(txt|md|json|yaml|yml|php|pl|py|cgi|twig|sh|bat)$ error [F]
|
||||
# Block all direct access to .md files:
|
||||
RewriteRule \.md$ error [F]
|
||||
# Block all direct access to files and folders beginning with a dot
|
||||
RewriteRule (^|/)\.(?!well-known) - [F]
|
||||
# Block access to specific files in the root folder
|
||||
RewriteRule ^(LICENSE\.txt|composer\.lock|composer\.json|\.htaccess)$ error [F]
|
||||
## End - Security
|
||||
|
||||
</IfModule>
|
||||
|
||||
# Begin - Prevent Browsing and Set Default Resources
|
||||
Options -Indexes
|
||||
DirectoryIndex index.php index.html index.htm
|
||||
# End - Prevent Browsing and Set Default Resources
|
||||
44
.phan/config.php
Normal file
44
.phan/config.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
return [
|
||||
"target_php_version" => null,
|
||||
'pretend_newer_core_functions_exist' => true,
|
||||
'allow_missing_properties' => false,
|
||||
'null_casts_as_any_type' => false,
|
||||
'null_casts_as_array' => false,
|
||||
'array_casts_as_null' => false,
|
||||
'strict_method_checking' => true,
|
||||
'quick_mode' => false,
|
||||
'simplify_ast' => false,
|
||||
'directory_list' => [
|
||||
'.',
|
||||
],
|
||||
"exclude_analysis_directory_list" => [
|
||||
'vendor/'
|
||||
],
|
||||
'exclude_file_list' => [
|
||||
'system/src/Grav/Common/Errors/Resources/layout.html.php',
|
||||
'tests/_support/AcceptanceTester.php',
|
||||
'tests/_support/FunctionalTester.php',
|
||||
'tests/_support/UnitTester.php',
|
||||
],
|
||||
'autoload_internal_extension_signatures' => [
|
||||
'memcached' => '.phan/internal_stubs/memcached.phan_php',
|
||||
'memcache' => '.phan/internal_stubs/memcache.phan_php',
|
||||
'redis' => '.phan/internal_stubs/Redis.phan_php',
|
||||
],
|
||||
'plugins' => [
|
||||
'AlwaysReturnPlugin',
|
||||
'UnreachableCodePlugin',
|
||||
'DuplicateArrayKeyPlugin',
|
||||
'PregRegexCheckerPlugin',
|
||||
'PrintfCheckerPlugin',
|
||||
],
|
||||
'suppress_issue_types' => [
|
||||
'PhanUnreferencedUseNormal',
|
||||
'PhanTypeObjectUnsetDeclaredProperty',
|
||||
'PhanTraitParentReference',
|
||||
'PhanTypeInvalidThrowsIsInterface',
|
||||
'PhanRequiredTraitNotAdded',
|
||||
'PhanDeprecatedFunction', // Uncomment this to see all the deprecated calls
|
||||
]
|
||||
];
|
||||
5153
.phan/internal_stubs/Redis.phan_php
Normal file
5153
.phan/internal_stubs/Redis.phan_php
Normal file
File diff suppressed because it is too large
Load diff
460
.phan/internal_stubs/memcache.phan_php
Normal file
460
.phan/internal_stubs/memcache.phan_php
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
<?php
|
||||
|
||||
// Start of memcache v.3.0.8
|
||||
|
||||
class MemcachePool {
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Open memcached server connection
|
||||
* @link https://php.net/manual/en/memcache.connect.php
|
||||
* @param string $host <p>
|
||||
* Point to the host where memcached is listening for connections. This parameter
|
||||
* may also specify other transports like <em>unix:///path/to/memcached.sock</em>
|
||||
* to use UNIX domain sockets, in this case <b>port</b> must also
|
||||
* be set to <em>0</em>.
|
||||
* </p>
|
||||
* @param int $port [optional] <p>
|
||||
* Point to the port where memcached is listening for connections. Set this
|
||||
* parameter to <em>0</em> when using UNIX domain sockets.
|
||||
* </p>
|
||||
* <p>
|
||||
* Please note: <b>port</b> defaults to
|
||||
* {@link https://php.net/manual/ru/memcache.ini.php#ini.memcache.default-port memcache.default_port}
|
||||
* if not specified. For this reason it is wise to specify the port
|
||||
* explicitly in this method call.
|
||||
* </p>
|
||||
* @param int $timeout [optional] <p>Value in seconds which will be used for connecting to the daemon. Think twice before changing the default value of 1 second - you can lose all the advantages of caching if your connection is too slow.</p>
|
||||
* @return bool <p>Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.</p>
|
||||
*/
|
||||
public function connect ($host, $port, $timeout = 1) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 2.0.0)<br/>
|
||||
* Add a memcached server to connection pool
|
||||
* @link https://php.net/manual/en/memcache.addserver.php
|
||||
* @param string $host <p>
|
||||
* Point to the host where memcached is listening for connections. This parameter
|
||||
* may also specify other transports like unix:///path/to/memcached.sock
|
||||
* to use UNIX domain sockets, in this case <i>port</i> must also
|
||||
* be set to 0.
|
||||
* </p>
|
||||
* @param int $port [optional] <p>
|
||||
* Point to the port where memcached is listening for connections.
|
||||
* Set this
|
||||
* parameter to 0 when using UNIX domain sockets.
|
||||
* </p>
|
||||
* <p>
|
||||
* Please note: <i>port</i> defaults to
|
||||
* memcache.default_port
|
||||
* if not specified. For this reason it is wise to specify the port
|
||||
* explicitly in this method call.
|
||||
* </p>
|
||||
* @param bool $persistent [optional] <p>
|
||||
* Controls the use of a persistent connection. Default to <b>TRUE</b>.
|
||||
* </p>
|
||||
* @param int $weight [optional] <p>
|
||||
* Number of buckets to create for this server which in turn control its
|
||||
* probability of it being selected. The probability is relative to the
|
||||
* total weight of all servers.
|
||||
* </p>
|
||||
* @param int $timeout [optional] <p>
|
||||
* Value in seconds which will be used for connecting to the daemon. Think
|
||||
* twice before changing the default value of 1 second - you can lose all
|
||||
* the advantages of caching if your connection is too slow.
|
||||
* </p>
|
||||
* @param int $retry_interval [optional] <p>
|
||||
* Controls how often a failed server will be retried, the default value
|
||||
* is 15 seconds. Setting this parameter to -1 disables automatic retry.
|
||||
* Neither this nor the <i>persistent</i> parameter has any
|
||||
* effect when the extension is loaded dynamically via <b>dl</b>.
|
||||
* </p>
|
||||
* <p>
|
||||
* Each failed connection struct has its own timeout and before it has expired
|
||||
* the struct will be skipped when selecting backends to serve a request. Once
|
||||
* expired the connection will be successfully reconnected or marked as failed
|
||||
* for another <i>retry_interval</i> seconds. The typical
|
||||
* effect is that each web server child will retry the connection about every
|
||||
* <i>retry_interval</i> seconds when serving a page.
|
||||
* </p>
|
||||
* @param bool $status [optional] <p>
|
||||
* Controls if the server should be flagged as online. Setting this parameter
|
||||
* to <b>FALSE</b> and <i>retry_interval</i> to -1 allows a failed
|
||||
* server to be kept in the pool so as not to affect the key distribution
|
||||
* algorithm. Requests for this server will then failover or fail immediately
|
||||
* depending on the <i>memcache.allow_failover</i> setting.
|
||||
* Default to <b>TRUE</b>, meaning the server should be considered online.
|
||||
* </p>
|
||||
* @param callable $failure_callback [optional] <p>
|
||||
* Allows the user to specify a callback function to run upon encountering an
|
||||
* error. The callback is run before failover is attempted. The function takes
|
||||
* two parameters, the hostname and port of the failed server.
|
||||
* </p>
|
||||
* @param int $timeoutms [optional] <p>
|
||||
* </p>
|
||||
* @return bool <b>TRUE</b> on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function addServer ($host, $port = 11211, $persistent = true, $weight = null, $timeout = 1, $retry_interval = 15, $status = true, callable $failure_callback = null, $timeoutms = null) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 2.1.0)<br/>
|
||||
* Changes server parameters and status at runtime
|
||||
* @link https://secure.php.net/manual/en/memcache.setserverparams.php
|
||||
* @param string $host <p>Point to the host where memcached is listening for connections.</p.
|
||||
* @param int $port [optional] <p>
|
||||
* Point to the port where memcached is listening for connections.
|
||||
* </p>
|
||||
* @param int $timeout [optional] <p>
|
||||
* Value in seconds which will be used for connecting to the daemon. Think twice before changing the default value of 1 second - you can lose all the advantages of caching if your connection is too slow.
|
||||
* </p>
|
||||
* @param int $retry_interval [optional] <p>
|
||||
* Controls how often a failed server will be retried, the default value
|
||||
* is 15 seconds. Setting this parameter to -1 disables automatic retry.
|
||||
* Neither this nor the <b>persistent</b> parameter has any
|
||||
* effect when the extension is loaded dynamically via {@link https://secure.php.net/manual/en/function.dl.php dl()}.
|
||||
* </p>
|
||||
* @param bool $status [optional] <p>
|
||||
* Controls if the server should be flagged as online. Setting this parameter
|
||||
* to <b>FALSE</b> and <b>retry_interval</b> to -1 allows a failed
|
||||
* server to be kept in the pool so as not to affect the key distribution
|
||||
* algorithm. Requests for this server will then failover or fail immediately
|
||||
* depending on the <b>memcache.allow_failover</b> setting.
|
||||
* Default to <b>TRUE</b>, meaning the server should be considered online.
|
||||
* </p>
|
||||
* @param callable $failure_callback [optional] <p>
|
||||
* Allows the user to specify a callback function to run upon encountering an error. The callback is run before failover is attempted.
|
||||
* The function takes two parameters, the hostname and port of the failed server.
|
||||
* </p>
|
||||
* @return bool <p>Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.</p>
|
||||
*/
|
||||
public function setServerParams ($host, $port = 11211, $timeout = 1, $retry_interval = 15, $status = true, callable $failure_callback = null) {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public function setFailureCallback () {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 2.1.0)<br/>
|
||||
* Returns server status
|
||||
* @link https://php.net/manual/en/memcache.getserverstatus.php
|
||||
* @param string $host Point to the host where memcached is listening for connections.
|
||||
* @param int $port Point to the port where memcached is listening for connections.
|
||||
* @return int Returns a the servers status. 0 if server is failed, non-zero otherwise
|
||||
*/
|
||||
public function getServerStatus ($host, $port = 11211) {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public function findServer () {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Return version of the server
|
||||
* @link https://php.net/manual/en/memcache.getversion.php
|
||||
* @return string|false Returns a string of server version number or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function getVersion () {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 2.0.0)<br/>
|
||||
* Add an item to the server. If the key already exists, the value will not be added and <b>FALSE</b> will be returned.
|
||||
* @link https://php.net/manual/en/memcache.add.php
|
||||
* @param string $key The key that will be associated with the item.
|
||||
* @param mixed $var The variable to store. Strings and integers are stored as is, other types are stored serialized.
|
||||
* @param int $flag [optional] <p>
|
||||
* Use <b>MEMCACHE_COMPRESSED</b> to store the item
|
||||
* compressed (uses zlib).
|
||||
* </p>
|
||||
* @param int $expire [optional] <p>Expiration time of the item.
|
||||
* If it's equal to zero, the item will never expire.
|
||||
* You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).</p>
|
||||
* @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure. Returns <b>FALSE</b> if such key already exist. For the rest Memcache::add() behaves similarly to Memcache::set().
|
||||
*/
|
||||
public function add ($key , $var, $flag = null, $expire = null) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Stores an item var with key on the memcached server. Parameter expire is expiration time in seconds.
|
||||
* If it's 0, the item never expires (but memcached server doesn't guarantee this item to be stored all the time,
|
||||
* it could be deleted from the cache to make place for other items).
|
||||
* You can use MEMCACHE_COMPRESSED constant as flag value if you want to use on-the-fly compression (uses zlib).
|
||||
* @link https://php.net/manual/en/memcache.set.php
|
||||
* @param string $key The key that will be associated with the item.
|
||||
* @param mixed $var The variable to store. Strings and integers are stored as is, other types are stored serialized.
|
||||
* @param int $flag [optional] Use MEMCACHE_COMPRESSED to store the item compressed (uses zlib).
|
||||
* @param int $expire [optional] Expiration time of the item. If it's equal to zero, the item will never expire. You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).
|
||||
* @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function set ($key, $var, $flag = null, $expire = null) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Replace value of the existing item
|
||||
* @link https://php.net/manual/en/memcache.replace.php
|
||||
* @param string $key <p>The key that will be associated with the item.</p>
|
||||
* @param mixed $var <p>The variable to store. Strings and integers are stored as is, other types are stored serialized.</p>
|
||||
* @param int $flag [optional] <p>Use <b>MEMCACHE_COMPRESSED</b> to store the item compressed (uses zlib).</p>
|
||||
* @param int $expire [optional] <p>Expiration time of the item. If it's equal to zero, the item will never expire. You can also use Unix timestamp or a number of seconds starting from current time, but in the latter case the number of seconds may not exceed 2592000 (30 days).</p>
|
||||
* @return bool Returns TRUE on success or FALSE on failure.
|
||||
*/
|
||||
public function replace ($key, $var, $flag = null, $expire = null) {}
|
||||
|
||||
public function cas () {}
|
||||
|
||||
public function append () {}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function prepend () {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Retrieve item from the server
|
||||
* @link https://php.net/manual/en/memcache.get.php
|
||||
* @param string|array $key <p>
|
||||
* The key or array of keys to fetch.
|
||||
* </p>
|
||||
* @param int|array $flags [optional] <p>
|
||||
* If present, flags fetched along with the values will be written to this parameter. These
|
||||
* flags are the same as the ones given to for example {@link https://php.net/manual/en/memcache.set.php Memcache::set()}.
|
||||
* The lowest byte of the int is reserved for pecl/memcache internal usage (e.g. to indicate
|
||||
* compression and serialization status).
|
||||
* </p>
|
||||
* @return string|array|false <p>
|
||||
* Returns the string associated with the <b>key</b> or
|
||||
* an array of found key-value pairs when <b>key</b> is an {@link https://php.net/manual/en/language.types.array.php array}.
|
||||
* Returns <b>FALSE</b> on failure, <b>key</b> is not found or
|
||||
* <b>key</b> is an empty {@link https://php.net/manual/en/language.types.array.php array}.
|
||||
* </p>
|
||||
*/
|
||||
public function get ($key, &$flags = null) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Delete item from the server
|
||||
* https://secure.php.net/manual/ru/memcache.delete.php
|
||||
* @param $key string The key associated with the item to delete.
|
||||
* @param $timeout int [optional] This deprecated parameter is not supported, and defaults to 0 seconds. Do not use this parameter.
|
||||
* @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function delete ($key, $timeout = 0 ) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Get statistics of the server
|
||||
* @link https://php.net/manual/ru/memcache.getstats.php
|
||||
* @param string $type [optional] <p>
|
||||
* The type of statistics to fetch.
|
||||
* Valid values are {reset, malloc, maps, cachedump, slabs, items, sizes}.
|
||||
* According to the memcached protocol spec these additional arguments "are subject to change for the convenience of memcache developers".</p>
|
||||
* @param int $slabid [optional] <p>
|
||||
* Used in conjunction with <b>type</b> set to
|
||||
* cachedump to identify the slab to dump from. The cachedump
|
||||
* command ties up the server and is strictly to be used for
|
||||
* debugging purposes.
|
||||
* </p>
|
||||
* @param int $limit [optional] <p>
|
||||
* Used in conjunction with <b>type</b> set to cachedump to limit the number of entries to dump.
|
||||
* </p>
|
||||
* @return array|false Returns an associative array of server statistics or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function getStats ($type = null, $slabid = null, $limit = 100) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 2.0.0)<br/>
|
||||
* Get statistics from all servers in pool
|
||||
* @link https://php.net/manual/en/memcache.getextendedstats.php
|
||||
* @param string $type [optional] <p>The type of statistics to fetch. Valid values are {reset, malloc, maps, cachedump, slabs, items, sizes}. According to the memcached protocol spec these additional arguments "are subject to change for the convenience of memcache developers".</p>
|
||||
* @param int $slabid [optional] <p>
|
||||
* Used in conjunction with <b>type</b> set to
|
||||
* cachedump to identify the slab to dump from. The cachedump
|
||||
* command ties up the server and is strictly to be used for
|
||||
* debugging purposes.
|
||||
* </p>
|
||||
* @param int $limit Used in conjunction with type set to cachedump to limit the number of entries to dump.
|
||||
* @return array|false Returns a two-dimensional associative array of server statistics or <b>FALSE</b>
|
||||
* Returns a two-dimensional associative array of server statistics or <b>FALSE</b>
|
||||
* on failure.
|
||||
*/
|
||||
public function getExtendedStats ($type = null, $slabid = null, $limit = 100) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 2.0.0)<br/>
|
||||
* Enable automatic compression of large values
|
||||
* @link https://php.net/manual/en/memcache.setcompressthreshold.php
|
||||
* @param int $thresold <p>Controls the minimum value length before attempting to compress automatically.</p>
|
||||
* @param float $min_saving [optional] <p>Specifies the minimum amount of savings to actually store the value compressed. The supplied value must be between 0 and 1. Default value is 0.2 giving a minimum 20% compression savings.</p>
|
||||
* @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function setCompressThreshold ($thresold, $min_saving = 0.2) {}
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Increment item's value
|
||||
* @link https://php.net/manual/en/memcache.increment.php
|
||||
* @param $key string Key of the item to increment.
|
||||
* @param $value int [optional] increment the item by <b>value</b>
|
||||
* @return int|false Returns new items value on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function increment ($key, $value = 1) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Decrement item's value
|
||||
* @link https://php.net/manual/en/memcache.decrement.php
|
||||
* @param $key string Key of the item do decrement.
|
||||
* @param $value int Decrement the item by <b>value</b>.
|
||||
* @return int|false Returns item's new value on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function decrement ($key, $value = 1) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.4.0)<br/>
|
||||
* Close memcached server connection
|
||||
* @link https://php.net/manual/en/memcache.close.php
|
||||
* @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function close () {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 1.0.0)<br/>
|
||||
* Flush all existing items at the server
|
||||
* @link https://php.net/manual/en/memcache.flush.php
|
||||
* @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function flush () {}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a connection to a set of memcache servers.
|
||||
* @link https://php.net/manual/en/class.memcache.php
|
||||
*/
|
||||
class Memcache extends MemcachePool {
|
||||
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.4.0)<br/>
|
||||
* Open memcached server persistent connection
|
||||
* @link https://php.net/manual/en/memcache.pconnect.php
|
||||
* @param string $host <p>
|
||||
* Point to the host where memcached is listening for connections. This parameter
|
||||
* may also specify other transports like unix:///path/to/memcached.sock
|
||||
* to use UNIX domain sockets, in this case <i>port</i> must also
|
||||
* be set to 0.
|
||||
* </p>
|
||||
* @param int $port [optional] <p>
|
||||
* Point to the port where memcached is listening for connections. Set this
|
||||
* parameter to 0 when using UNIX domain sockets.
|
||||
* </p>
|
||||
* @param int $timeout [optional] <p>
|
||||
* Value in seconds which will be used for connecting to the daemon. Think
|
||||
* twice before changing the default value of 1 second - you can lose all
|
||||
* the advantages of caching if your connection is too slow.
|
||||
* </p>
|
||||
* @return mixed a Memcache object or <b>FALSE</b> on failure.
|
||||
*/
|
||||
public function pconnect ($host, $port, $timeout = 1) {}
|
||||
}
|
||||
|
||||
// string $host [, int $port [, int $timeout ]]
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)</br>
|
||||
* Memcache::connect — Open memcached server connection
|
||||
* @link https://php.net/manual/en/memcache.connect.php
|
||||
* @param string $host <p>
|
||||
* Point to the host where memcached is listening for connections.
|
||||
* This parameter may also specify other transports like
|
||||
* unix:///path/to/memcached.sock to use UNIX domain sockets,
|
||||
* in this case port must also be set to 0.
|
||||
* </p>
|
||||
* @param int $port [optional] <p>
|
||||
* Point to the port where memcached is listening for connections.
|
||||
* Set this parameter to 0 when using UNIX domain sockets.
|
||||
* Note: port defaults to memcache.default_port if not specified.
|
||||
* For this reason it is wise to specify the port explicitly in this method call.
|
||||
* </p>
|
||||
* @param int $timeout [optional] <p>
|
||||
* Value in seconds which will be used for connecting to the daemon.
|
||||
* </p>
|
||||
* @return bool Returns <b>TRUE</b> on success or <b>FALSE</b> on failure.
|
||||
*/
|
||||
function memcache_connect ($host, $port, $timeout = 1) {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.4.0)
|
||||
* Memcache::pconnect — Open memcached server persistent connection
|
||||
*
|
||||
* @link https://php.net/manual/en/memcache.pconnect.php#example-5242
|
||||
* @param $host
|
||||
* @param null $port
|
||||
* @param int $timeout
|
||||
* @return Memcache
|
||||
*/
|
||||
function memcache_pconnect ($host, $port=null, $timeout=1) {}
|
||||
|
||||
function memcache_add_server () {}
|
||||
|
||||
function memcache_set_server_params () {}
|
||||
|
||||
function memcache_set_failure_callback () {}
|
||||
|
||||
function memcache_get_server_status () {}
|
||||
|
||||
function memcache_get_version () {}
|
||||
|
||||
function memcache_add () {}
|
||||
|
||||
function memcache_set () {}
|
||||
|
||||
function memcache_replace () {}
|
||||
|
||||
function memcache_cas () {}
|
||||
|
||||
function memcache_append () {}
|
||||
|
||||
function memcache_prepend () {}
|
||||
|
||||
function memcache_get () {}
|
||||
|
||||
function memcache_delete () {}
|
||||
|
||||
/**
|
||||
* (PECL memcache >= 0.2.0)<br/>
|
||||
* Turn debug output on/off
|
||||
* @link https://php.net/manual/en/function.memcache-debug.php
|
||||
* @param bool $on_off <p>
|
||||
* Turns debug output on if equals to <b>TRUE</b>.
|
||||
* Turns debug output off if equals to <b>FALSE</b>.
|
||||
* </p>
|
||||
* @return bool <b>TRUE</b> if PHP was built with --enable-debug option, otherwise
|
||||
* returns <b>FALSE</b>.
|
||||
*/
|
||||
function memcache_debug ($on_off) {}
|
||||
|
||||
function memcache_get_stats () {}
|
||||
|
||||
function memcache_get_extended_stats () {}
|
||||
|
||||
function memcache_set_compress_threshold () {}
|
||||
|
||||
function memcache_increment () {}
|
||||
|
||||
function memcache_decrement () {}
|
||||
|
||||
function memcache_close () {}
|
||||
|
||||
function memcache_flush () {}
|
||||
|
||||
define ('MEMCACHE_COMPRESSED', 2);
|
||||
define ('MEMCACHE_USER1', 65536);
|
||||
define ('MEMCACHE_USER2', 131072);
|
||||
define ('MEMCACHE_USER3', 262144);
|
||||
define ('MEMCACHE_USER4', 524288);
|
||||
define ('MEMCACHE_HAVE_SESSION', 1);
|
||||
|
||||
// End of memcache v.3.0.8
|
||||
?>
|
||||
1308
.phan/internal_stubs/memcached.phan_php
Normal file
1308
.phan/internal_stubs/memcached.phan_php
Normal file
File diff suppressed because it is too large
Load diff
26
LEGACY_REPOSITORIES.md
Normal file
26
LEGACY_REPOSITORIES.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Legacy repository origins
|
||||
|
||||
This application was consolidated from multiple working directories into one
|
||||
monorepo. The first monorepo commit represents the known-good production state
|
||||
at the time of consolidation.
|
||||
|
||||
## Main application
|
||||
|
||||
- Previous repository: `ssh://FORGELAN/makearmy/makearmy-app.git`
|
||||
- Previous HEAD: `3614acd2974151da45b6919f048f5171619ec3e7`
|
||||
- Previous branch: `main`
|
||||
- Live-only modifications were incorporated into the monorepo baseline.
|
||||
|
||||
## BGBye
|
||||
|
||||
- Previous upstream: `https://github.com/MangoLion/bgbye.git`
|
||||
- Imported HEAD: `5255025e2360cc33599c7c96ed904f1bc49e9ae8`
|
||||
- The imported code was substantially modified locally.
|
||||
|
||||
## SVGnest
|
||||
|
||||
- Previous upstream: `https://github.com/Jack000/SVGnest`
|
||||
- Imported HEAD: `1248dc21efd3f90d1aa52ba5785e27e5217ed2c9`
|
||||
- The imported code was substantially modified locally.
|
||||
|
||||
The original nested Git metadata was archived separately before consolidation.
|
||||
40
README.md
Normal file
40
README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Laser Everything Database
|
||||
|
||||
Monorepo for the Laser Everything database application and its supporting
|
||||
services.
|
||||
|
||||
## Components
|
||||
|
||||
- `app/` — main Next.js application
|
||||
- `bgbye/` — customized background-removal service
|
||||
- `dashboard/` — static application dashboard
|
||||
- `svgnest/` — customized SVG nesting application
|
||||
- `app/files/` — curated manuals, reference material, software and projects
|
||||
- `docker-compose.yml` — production service definition
|
||||
|
||||
## Runtime data excluded from Git
|
||||
|
||||
The following are created or downloaded on the deployment host and are not
|
||||
stored in this repository:
|
||||
|
||||
- environment and secret files;
|
||||
- JavaScript dependencies and Next.js build output;
|
||||
- Python virtual environments;
|
||||
- BGBye model downloads and caches;
|
||||
- logs and temporary files.
|
||||
|
||||
The production environment currently expects:
|
||||
|
||||
- `app/.env.local`
|
||||
- `bgbye/.cache/`
|
||||
- `bgbye/.u2net/`
|
||||
- `bgbye/.ormbg/`
|
||||
|
||||
These must be provisioned separately when deploying a fresh checkout.
|
||||
|
||||
## Repository structure
|
||||
|
||||
This repository replaces several older nested repositories. BGBye and SVGnest
|
||||
are incorporated directly as locally maintained source trees rather than Git
|
||||
submodules or automatically updated upstream dependencies. See their
|
||||
`UPSTREAM.md` files and `LEGACY_REPOSITORIES.md` for provenance.
|
||||
19
app/.dockerignore
Normal file
19
app/.dockerignore
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
**/.next
|
||||
|
||||
files
|
||||
app/files
|
||||
**/venv
|
||||
**/__pycache__
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
debug
|
||||
*.bak
|
||||
*.zip
|
||||
.DS_Store
|
||||
10
app/.gitignore
vendored
Normal file
10
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
!public/svgnest/**
|
||||
|
||||
# Next.js build output
|
||||
.next/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
49
app/Dockerfile
Normal file
49
app/Dockerfile
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies needed to build
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Reuse installed dependencies
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy app source
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# After building, remove dev dependencies so runtime stays smaller
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:20-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV HOME=/tmp
|
||||
|
||||
# Create writable directories
|
||||
RUN mkdir -p /app/files /app/.next/cache /tmp && \
|
||||
chown -R 1000:1000 /app /tmp
|
||||
|
||||
# Copy only runtime files
|
||||
COPY --from=build --chown=1000:1000 /app/package.json ./package.json
|
||||
COPY --from=build --chown=1000:1000 /app/next.config.js ./next.config.js
|
||||
COPY --from=build --chown=1000:1000 /app/public ./public
|
||||
COPY --from=build --chown=1000:1000 /app/.next ./.next
|
||||
COPY --from=build --chown=1000:1000 /app/node_modules ./node_modules
|
||||
|
||||
USER 1000:1000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "3000", "-H", "0.0.0.0"]
|
||||
18
app/app/api/_lib/auth.ts
Normal file
18
app/app/api/_lib/auth.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// app/api/_lib/auth.ts
|
||||
import { getUserBearerFromRequest } from "@/lib/directus";
|
||||
|
||||
export function requireBearer(req: Request): string {
|
||||
const bearer = getUserBearerFromRequest(req);
|
||||
if (!bearer) {
|
||||
const err: any = new Error("UNAUTHENTICATED");
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
return bearer;
|
||||
}
|
||||
|
||||
export function jsonError(e: any, fallback = 500) {
|
||||
const status = e?.status ?? fallback;
|
||||
const body = { error: e?.message || "ERROR", detail: e?.detail ?? String(e) };
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
60
app/app/api/account/avatar/route.ts
Normal file
60
app/app/api/account/avatar/route.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// app/api/account/avatar/route.ts
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
|
||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
const AVATAR_FOLDER_ID = process.env.DIRECTUS_AVATAR_FOLDER_ID || "";
|
||||
|
||||
function bad(msg: string, code = 400) {
|
||||
return NextResponse.json({ error: msg }, { status: code });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (!AVATAR_FOLDER_ID) {
|
||||
return bad("Server misconfiguration: DIRECTUS_AVATAR_FOLDER_ID is not set", 500);
|
||||
}
|
||||
|
||||
const bearer = requireBearer(req);
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof Blob)) return bad("Missing file");
|
||||
|
||||
// Upload directly into the configured avatars folder
|
||||
const up = new FormData();
|
||||
up.set("file", file, (file as any).name || "avatar.bin");
|
||||
up.set("folder", AVATAR_FOLDER_ID);
|
||||
|
||||
const r1 = await fetch(`${API}/files`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: up,
|
||||
});
|
||||
const j1 = await r1.json().catch(() => ({}));
|
||||
if (!r1.ok) {
|
||||
const msg = j1?.errors?.[0]?.message || "Upload failed";
|
||||
return bad(msg, r1.status);
|
||||
}
|
||||
const fileId: string = j1?.data?.id ?? j1?.id;
|
||||
|
||||
// Link file to the current user
|
||||
const r2 = await fetch(`${API}/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatar: fileId }),
|
||||
});
|
||||
const j2 = await r2.json().catch(() => ({}));
|
||||
if (!r2.ok) {
|
||||
const msg = j2?.errors?.[0]?.message || "Link avatar failed";
|
||||
return bad(msg, r2.status);
|
||||
}
|
||||
|
||||
// Respond with the updated avatar reference
|
||||
return NextResponse.json({ ok: true, avatar: j2?.data?.avatar ?? { id: fileId } });
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Upload error", e?.status || 500);
|
||||
}
|
||||
}
|
||||
110
app/app/api/account/password/route.ts
Normal file
110
app/app/api/account/password/route.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// app/api/account/password/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
import { loginDirectus } from "@/lib/directus";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const bad = (m: string, c = 400, extra?: Record<string, any>) =>
|
||||
NextResponse.json(extra ? { error: m, ...extra } : { error: m }, { status: c });
|
||||
|
||||
function readCookie(req: Request, name: string): string | null {
|
||||
const cookie = req.headers.get("cookie") || "";
|
||||
const m = cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`));
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
function jwtPayload(token: string | null): any | null {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const [, payload] = token.split(".");
|
||||
if (!payload) return null;
|
||||
const json = JSON.parse(
|
||||
Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
|
||||
);
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(req: Request) {
|
||||
const bearer = requireBearer(req);
|
||||
|
||||
const body = await req.json().catch(() => ({} as any));
|
||||
const current = String(body?.current ?? body?.current_password ?? "").trim();
|
||||
const next = String(body?.next ?? body?.new_password ?? "").trim();
|
||||
// NEW: allow client to provide identifier explicitly
|
||||
let identifier = String(body?.identifier ?? "").trim();
|
||||
|
||||
if (!current || !next) return bad("Missing current and/or new password");
|
||||
if (next.length < 8) return bad("Password must be at least 8 characters");
|
||||
|
||||
// 1) Prefer client-provided identifier if present
|
||||
// (email or username — whatever your Directus login accepts)
|
||||
if (!identifier) {
|
||||
// 2) Try to read from /users/me (email or username), honoring role perms
|
||||
const meRes = await fetch(`${API}/users/me?fields=id,email,username,provider`, {
|
||||
headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
const me = await meRes.json().catch(() => ({}));
|
||||
if (meRes.ok) {
|
||||
const provider: string = me?.data?.provider ?? me?.provider ?? "local";
|
||||
if (provider !== "local") {
|
||||
return bad("Password managed by external provider", 400, { debug: `provider=${provider}` });
|
||||
}
|
||||
const email: string | undefined = me?.data?.email ?? me?.email ?? undefined;
|
||||
const username: string | undefined = me?.data?.username ?? me?.username ?? undefined;
|
||||
identifier = email || username || "";
|
||||
} else {
|
||||
// If we can’t read user fields (permissions), keep going
|
||||
}
|
||||
}
|
||||
|
||||
if (!identifier) {
|
||||
// 3) Try to decode JWT in ma_at — some Directus builds include email in JWT
|
||||
const token = readCookie(req, "ma_at");
|
||||
const claims = jwtPayload(token);
|
||||
const fromJwt = (claims?.email as string) || (claims?.user?.email as string) || "";
|
||||
identifier = fromJwt?.trim() || "";
|
||||
}
|
||||
|
||||
if (!identifier) {
|
||||
// Last resort: ask client to pass identifier next time
|
||||
return bad("No login identifier available for this user", 400, {
|
||||
debug: "missing email and username; pass `identifier` in request body",
|
||||
});
|
||||
}
|
||||
|
||||
// 4) Verify CURRENT password by logging in with identifier
|
||||
const auth = await loginDirectus(identifier, current).catch(() => null);
|
||||
const access = auth?.access_token ?? auth?.data?.access_token;
|
||||
if (!access) {
|
||||
return bad("Current password is incorrect", 401);
|
||||
}
|
||||
|
||||
// 5) Update password using ONLY the 'password' field
|
||||
const patchRes = await fetch(`${API}/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ password: next }),
|
||||
});
|
||||
|
||||
const j = await patchRes.json().catch(() => ({}));
|
||||
if (!patchRes.ok) {
|
||||
const reason =
|
||||
j?.errors?.[0]?.message || j?.error || (typeof j === "string" ? j : "") || "Password change failed";
|
||||
return bad(reason, patchRes.status);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) { return handle(req); }
|
||||
export async function PATCH(req: Request) { return handle(req); }
|
||||
75
app/app/api/account/profile/route.ts
Normal file
75
app/app/api/account/profile/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// app/api/account/profile/route.ts
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
|
||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c });
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Failed to load profile", e?.status || 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
||||
|
||||
// Enforce recent re-auth for sensitive fields
|
||||
const SENSITIVE = new Set(["email", "username"]);
|
||||
const wantsSensitive = Object.keys(body).some((k) => SENSITIVE.has(k));
|
||||
if (wantsSensitive) {
|
||||
const cookie = req.headers.get("cookie") || "";
|
||||
const hasRecentAuth = /(?:^|;\s*)ma_ra=1(?:;|$)/.test(cookie);
|
||||
if (!hasRecentAuth) {
|
||||
return NextResponse.json({ error: "Re-authentication required" }, { status: 428 });
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {};
|
||||
if (typeof (body as any).first_name === "string")
|
||||
payload.first_name = (body as any).first_name.trim();
|
||||
if (typeof (body as any).last_name === "string")
|
||||
payload.last_name = (body as any).last_name.trim();
|
||||
if (typeof (body as any).location === "string")
|
||||
payload.location = (body as any).location.trim();
|
||||
if ("email" in body) {
|
||||
const e = String((body as any).email ?? "").trim();
|
||||
payload.email = e ? e : null; // optional; blank clears it
|
||||
}
|
||||
|
||||
if (!Object.keys(payload).length) return bad("No changes");
|
||||
|
||||
const r = await fetch(`${API}/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) return bad(j?.errors?.[0]?.message || "Update failed", r.status);
|
||||
|
||||
// Single-use recent-auth: clear ma_ra after sensitive success
|
||||
if (wantsSensitive) {
|
||||
const resp = NextResponse.json({ ok: true });
|
||||
resp.cookies.set({
|
||||
name: "ma_ra",
|
||||
value: "",
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Unexpected error", e?.status || 500);
|
||||
}
|
||||
}
|
||||
101
app/app/api/account/route.ts
Normal file
101
app/app/api/account/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// app/api/account/route.ts
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
|
||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
const bad = (m: string, c = 400) => NextResponse.json({ error: m }, { status: c });
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
/** GET: current user's profile */
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req); // ← pass req
|
||||
const fields =
|
||||
"id,username,first_name,last_name,email,location,avatar.id,avatar.filename_download";
|
||||
|
||||
const res = await fetch(
|
||||
`${API}/users/me?fields=${encodeURIComponent(fields)}`,
|
||||
{ headers: { Authorization: `Bearer ${bearer}` }, cache: "no-store" }
|
||||
);
|
||||
const j = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = j?.errors?.[0]?.message || "Failed to load profile";
|
||||
return NextResponse.json({ error: msg }, { status: res.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, user: j?.data ?? j });
|
||||
} catch (e: any) {
|
||||
const status = e?.status === 401 ? 401 : e?.status || 500;
|
||||
return bad(e?.message || "Unexpected error", status);
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH: update editable fields for current user */
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const body = await req.json().catch(() => ({} as Record<string, unknown>));
|
||||
|
||||
// Enforce recent re-auth for sensitive fields (email/username)
|
||||
const SENSITIVE = new Set(["email", "username"]);
|
||||
const wantsSensitive = Object.keys(body).some((k) => SENSITIVE.has(k));
|
||||
if (wantsSensitive) {
|
||||
const cookie = req.headers.get("cookie") || "";
|
||||
const hasRecentAuth = /(?:^|;\s*)ma_ra=1(?:;|$)/.test(cookie);
|
||||
if (!hasRecentAuth) {
|
||||
return NextResponse.json(
|
||||
{ error: "Re-authentication required" },
|
||||
{ status: 428 } // Precondition Required
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {};
|
||||
if (typeof body.first_name === "string") payload.first_name = body.first_name.trim();
|
||||
if (typeof body.last_name === "string") payload.last_name = body.last_name.trim();
|
||||
if ("email" in body) {
|
||||
const e = String((body as any).email ?? "").trim();
|
||||
payload.email = e ? e : null; // optional; blank clears
|
||||
}
|
||||
if (typeof body.location === "string") payload.location = body.location.trim();
|
||||
if (typeof body.avatar === "string") payload.avatar = body.avatar; // file id
|
||||
// (username is read-only in UI; if you decide to allow it later, payload.username = ...)
|
||||
|
||||
if (!Object.keys(payload).length) return bad("No changes");
|
||||
|
||||
const res = await fetch(`${API}/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = j?.errors?.[0]?.message || "Update failed";
|
||||
return NextResponse.json({ error: msg }, { status: res.status });
|
||||
}
|
||||
|
||||
// Success: if we just did a sensitive change, clear recent-auth cookie (single-use)
|
||||
if (wantsSensitive) {
|
||||
const resp = NextResponse.json({ ok: true });
|
||||
resp.cookies.set({
|
||||
name: "ma_ra",
|
||||
value: "",
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
const status = e?.status === 401 ? 401 : e?.status || 500;
|
||||
return bad(e?.message || "Unexpected error", status);
|
||||
}
|
||||
}
|
||||
82
app/app/api/auth/login/route.ts
Normal file
82
app/app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// app/api/auth/login/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { emailForUsername, loginDirectus } from "@/lib/directus";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({} as any));
|
||||
const identifier =
|
||||
String(body?.identifier ?? body?.email ?? body?.username ?? "").trim();
|
||||
const password = String(body?.password ?? "").trim();
|
||||
|
||||
if (!identifier || !password) {
|
||||
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1) Try Directus directly with the identifier (email OR username)
|
||||
// Directus expects the field name "email" for both.
|
||||
const tryIds: string[] = [identifier];
|
||||
|
||||
// 2) Fallback: if it doesn’t look like an email, try the canonical email (if any)
|
||||
if (!identifier.includes("@")) {
|
||||
try {
|
||||
const em = await emailForUsername(identifier); // returns string|null
|
||||
if (em && em !== identifier) tryIds.push(em);
|
||||
} catch {
|
||||
// ignore lookup errors, we'll just rely on the first attempt
|
||||
}
|
||||
}
|
||||
|
||||
let tokens: any = null;
|
||||
let lastErr: any = null;
|
||||
for (const id of tryIds) {
|
||||
try {
|
||||
tokens = await loginDirectus(id, password); // { access_token, refresh_token, expires? }
|
||||
if (tokens) break;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokens?.access_token) {
|
||||
const msg =
|
||||
lastErr?.response?.data?.errors?.[0]?.message ||
|
||||
lastErr?.response?.data?.error ||
|
||||
lastErr?.message ||
|
||||
"Invalid credentials.";
|
||||
return NextResponse.json({ error: msg }, { status: 401 });
|
||||
}
|
||||
|
||||
// Set HttpOnly cookies for your middleware
|
||||
const maxAge = 60 * 60; // 1h
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set("ma_at", tokens.access_token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
maxAge,
|
||||
});
|
||||
if (tokens.refresh_token) {
|
||||
res.cookies.set("ma_rt", tokens.refresh_token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
maxAge: 60 * 60 * 24 * 30, // 30d
|
||||
});
|
||||
}
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err?.response?.data?.errors?.[0]?.message ||
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
"Login failed";
|
||||
const status = /unauth|invalid|credential/i.test(message) ? 401 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
28
app/app/api/auth/logout/route.ts
Normal file
28
app/app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// app/api/auth/logout/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
export async function POST(_req: NextRequest) {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
|
||||
res.cookies.set({
|
||||
name: "ma_at",
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
path: "/",
|
||||
expires: new Date(0), // expire immediately
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Optional: support GET if you ever link to /api/auth/logout directly
|
||||
export async function GET(_req: NextRequest) {
|
||||
return POST(_req);
|
||||
}
|
||||
66
app/app/api/auth/reconfirm/route.ts
Normal file
66
app/app/api/auth/reconfirm/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// app/api/auth/reconfirm/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { emailForUsername, loginDirectus } from "@/lib/directus";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({} as any));
|
||||
const identifier = String(body?.identifier ?? "").trim();
|
||||
const password = String(body?.password ?? "").trim();
|
||||
|
||||
if (!identifier || !password) {
|
||||
return NextResponse.json({ error: "Missing credentials" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Resolve identifier -> email (username or email accepted)
|
||||
const email = identifier.includes("@") ? identifier : await emailForUsername(identifier);
|
||||
if (!email) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
|
||||
const auth = await loginDirectus(email, password);
|
||||
const access = auth?.access_token ?? auth?.data?.access_token;
|
||||
const expiresSec = auth?.expires ?? auth?.data?.expires;
|
||||
|
||||
if (!access) {
|
||||
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
|
||||
}
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
|
||||
// Refresh the access token cookie
|
||||
const maxAge =
|
||||
typeof expiresSec === "number" ? Math.max(0, Math.floor(expiresSec)) : 60 * 60 * 8;
|
||||
res.cookies.set({
|
||||
name: "ma_at",
|
||||
value: access,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge,
|
||||
});
|
||||
|
||||
// Short-lived client-visible flag: “recently authenticated” (5 minutes)
|
||||
res.cookies.set({
|
||||
name: "ma_ra",
|
||||
value: "1",
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
path: "/",
|
||||
maxAge: 5 * 60,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (err: any) {
|
||||
const msg =
|
||||
err?.response?.data?.errors?.[0]?.message ||
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
"Re-auth failed";
|
||||
const status = /invalid|credential/i.test(msg) ? 401 : 400;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
}
|
||||
114
app/app/api/auth/register/route.ts
Normal file
114
app/app/api/auth/register/route.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// app/api/auth/register/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
const SERVICE_TOKEN =
|
||||
process.env.DIRECTUS_SERVICE_TOKEN ||
|
||||
process.env.DIRECTUS_ADMIN_TOKEN ||
|
||||
process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || "";
|
||||
const DEFAULT_ROLE = process.env.DIRECTUS_DEFAULT_ROLE || undefined;
|
||||
const SECURE = process.env.NODE_ENV === "production";
|
||||
|
||||
function bad(message: string, status = 400) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
async function directusLogin(email: string, password: string) {
|
||||
const r = await fetch(`${DIRECTUS}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
cache: "no-store",
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j?.errors?.[0]?.message || j?.message || `Login failed (${r.status})`);
|
||||
return j?.data || j;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (!DIRECTUS) return bad("Missing DIRECTUS_URL/NEXT_PUBLIC_API_BASE_URL", 500);
|
||||
if (!SERVICE_TOKEN) return bad("Missing DIRECTUS_SERVICE_TOKEN / admin token", 500);
|
||||
|
||||
const body = await req.json().catch(() => ({} as any));
|
||||
const email = String(body?.email ?? "").trim().toLowerCase();
|
||||
const username = String(body?.username ?? "").trim();
|
||||
const password = String(body?.password ?? "").trim();
|
||||
const confirm = String(body?.confirmPassword ?? body?.confirm ?? "").trim();
|
||||
|
||||
if (!email || !username || !password || !confirm) return bad("All fields are required");
|
||||
if (!EMAIL_RE.test(email)) return bad("Enter a valid email address");
|
||||
if (password.length < 8) return bad("Password must be at least 8 characters");
|
||||
if (password !== confirm) return bad("Passwords do not match");
|
||||
|
||||
// Optional pre-check to return a friendly 409 instead of a generic Directus error
|
||||
const existsRes = await fetch(
|
||||
`${DIRECTUS}/users?filter[_or][0][email][_eq]=${encodeURIComponent(email)}` +
|
||||
`&filter[_or][1][username][_eq]=${encodeURIComponent(username)}` +
|
||||
`&fields=id,email,username&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${SERVICE_TOKEN}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
const existsJson = await existsRes.json().catch(() => ({}));
|
||||
if (Array.isArray(existsJson?.data) && existsJson.data.length > 0) {
|
||||
return bad("Email or username already in use", 409);
|
||||
}
|
||||
|
||||
// Create user with sane defaults
|
||||
const createPayload: any = {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
};
|
||||
if (DEFAULT_ROLE) createPayload.role = DEFAULT_ROLE;
|
||||
|
||||
const createRes = await fetch(`${DIRECTUS}/users`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${SERVICE_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(createPayload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const cj = await createRes.json().catch(() => ({}));
|
||||
if (!createRes.ok) {
|
||||
const msg = cj?.errors?.[0]?.message || cj?.message || `User create failed (${createRes.status})`;
|
||||
return bad(msg, createRes.status || 500);
|
||||
}
|
||||
|
||||
// Auto-login (email-based; directus expects "email" even though it's an identifier)
|
||||
const tokens = await directusLogin(email, password);
|
||||
|
||||
const res = NextResponse.json({ ok: true, id: cj?.data?.id || null }, { status: 201 });
|
||||
if (tokens?.access_token) {
|
||||
res.cookies.set("ma_at", tokens.access_token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: SECURE,
|
||||
maxAge: 60 * 60, // 1h
|
||||
});
|
||||
}
|
||||
if (tokens?.refresh_token) {
|
||||
res.cookies.set("ma_rt", tokens.refresh_token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: SECURE,
|
||||
maxAge: 60 * 60 * 24 * 30, // 30d
|
||||
});
|
||||
}
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Registration error", e?.status || 500);
|
||||
}
|
||||
}
|
||||
47
app/app/api/bgbye/process/route.ts
Normal file
47
app/app/api/bgbye/process/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Proxies multipart POSTs (file + method) to the bgbye upstream.
|
||||
* Set BG_BYE_UPSTREAM to the FULL endpoint that accepts the form:
|
||||
* e.g. http://127.0.0.1:8010/process
|
||||
* http://localhost:8010/api/removebg
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const upstream = process.env.BG_BYE_UPSTREAM;
|
||||
if (!upstream) {
|
||||
return NextResponse.json(
|
||||
{ error: "BG_BYE_UPSTREAM is not configured on the server" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await req.formData();
|
||||
|
||||
// Forward the form as-is to the upstream
|
||||
const ures = await fetch(upstream, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
// Let undici set boundary; don't set Content-Type manually
|
||||
});
|
||||
|
||||
const contentType = ures.headers.get("content-type") || "application/octet-stream";
|
||||
const status = ures.status;
|
||||
|
||||
// Stream/buffer back to the client preserving content-type
|
||||
const ab = await ures.arrayBuffer();
|
||||
return new Response(Buffer.from(ab), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
// Allow the client to see error text if upstream returns text
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || String(err);
|
||||
return NextResponse.json({ error: `Proxy error: ${msg}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
52
app/app/api/claims/route.ts
Normal file
52
app/app/api/claims/route.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// app/api/claims/route.ts
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_BASE_URL!; // e.g. https://directus.your.tld
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { target_collection, target_id } = await req.json();
|
||||
|
||||
if (!target_collection || !target_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing target_collection or target_id' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Next 15: cookies() is async
|
||||
const jar = await cookies();
|
||||
const token =
|
||||
jar.get('directus_access_token')?.value ??
|
||||
jar.get('ma_at')?.value ??
|
||||
'';
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const r = await fetch(`${API}/items/user_claims`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ target_collection, target_id }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const data = await r.json().catch(() => ({} as any));
|
||||
|
||||
if (!r.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data?.errors?.[0]?.message ?? data?.message ?? 'Directus error' },
|
||||
{ status: r.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, data }, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
17
app/app/api/debug/meta/route.ts
Normal file
17
app/app/api/debug/meta/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// app/api/debug/meta/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
export async function GET() {
|
||||
let buildId = null;
|
||||
try {
|
||||
buildId = (await fs.readFile(process.cwd() + "/.next/BUILD_ID", "utf8")).trim();
|
||||
} catch {}
|
||||
return NextResponse.json({
|
||||
env: process.env.NEXT_PUBLIC_ENV || null,
|
||||
nodeEnv: process.env.NODE_ENV || null,
|
||||
buildId,
|
||||
image: process.env.IMAGE_TAG || null,
|
||||
commit: process.env.COMMIT_SHA || null,
|
||||
time: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
14
app/app/api/debug/whoami/route.ts
Normal file
14
app/app/api/debug/whoami/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// app/api/debug/whoami/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { dxGET } from "@/lib/directus";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const me = await dxGET<any>("/users/me?fields=id,username", bearer);
|
||||
return NextResponse.json(me, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message || "err" }, { status: e?.status ?? 500 });
|
||||
}
|
||||
}
|
||||
84
app/app/api/dx/[...path]/route.ts
Normal file
84
app/app/api/dx/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// app/api/dx/[...path]/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { dxGET, dxPOST, dxPATCH, dxDELETE } from "@/lib/directus";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic"; // these are true proxies, never prerender
|
||||
|
||||
function buildPath(req: Request, context: any) {
|
||||
const search = new URL(req.url).search || "";
|
||||
const params = (context?.params ?? {}) as { path?: string[] };
|
||||
const pathParts = Array.isArray(params.path) ? params.path : [];
|
||||
return `/${pathParts.join("/")}${search}`;
|
||||
}
|
||||
|
||||
// GET /api/dx/<anything>?<query>
|
||||
export async function GET(req: Request, context: any) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const p = buildPath(req, context);
|
||||
const json = await dxGET<any>(p, bearer);
|
||||
return NextResponse.json(json, { status: 200 });
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? 500;
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST JSON → /api/dx/<path>
|
||||
export async function POST(req: Request, context: any) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const p = buildPath(req, context);
|
||||
const body = await req.json().catch(() => {
|
||||
throw new Error("Invalid JSON body");
|
||||
});
|
||||
const json = await dxPOST<any>(p, bearer, body);
|
||||
return NextResponse.json(json, { status: 200 });
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? 500;
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH JSON → /api/dx/<path>
|
||||
export async function PATCH(req: Request, context: any) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const p = buildPath(req, context);
|
||||
const body = await req.json().catch(() => {
|
||||
throw new Error("Invalid JSON body");
|
||||
});
|
||||
const json = await dxPATCH<any>(p, bearer, body);
|
||||
return NextResponse.json(json, { status: 200 });
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? 500;
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE → /api/dx/<path>
|
||||
export async function DELETE(req: Request, context: any) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const p = buildPath(req, context);
|
||||
const json = await dxDELETE<any>(p, bearer);
|
||||
return NextResponse.json(json, { status: 200 });
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? 500;
|
||||
return NextResponse.json(
|
||||
{ errors: [{ message: e?.message || "Directus proxy error", detail: e?.detail }] },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
}
|
||||
42
app/app/api/files/download/route.ts
Normal file
42
app/app/api/files/download/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import fsp from "fs/promises";
|
||||
import path from "path";
|
||||
import mime from "mime";
|
||||
|
||||
const ROOT = (process.env.FILES_ROOT || "/files").trim();
|
||||
|
||||
function safeJoin(root: string, reqPath: string) {
|
||||
const clean = (reqPath || "").replace(/^\/+/, "");
|
||||
const joined = path.normalize(path.join(root, clean));
|
||||
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
|
||||
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
|
||||
throw new Error("Path traversal blocked");
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const reqPath = searchParams.get("path") || "";
|
||||
if (!reqPath) return new NextResponse("Missing path", { status: 400 });
|
||||
|
||||
const abs = safeJoin(ROOT, reqPath);
|
||||
const stat = await fsp.stat(abs).catch(() => null);
|
||||
if (!stat || !stat.isFile()) return new NextResponse("Not found", { status: 404 });
|
||||
|
||||
const filename = path.basename(abs);
|
||||
const ctype = mime.getType(abs) || "application/octet-stream";
|
||||
const stream = fs.createReadStream(abs);
|
||||
return new NextResponse(stream as any, {
|
||||
headers: {
|
||||
"Content-Type": ctype,
|
||||
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return new NextResponse(String(e?.message || e), { status: 400 });
|
||||
}
|
||||
}
|
||||
62
app/app/api/files/list/route.ts
Normal file
62
app/app/api/files/list/route.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const ROOT = (process.env.FILES_ROOT || "/files").trim();
|
||||
|
||||
function safeJoin(root: string, reqPath: string) {
|
||||
const clean = (reqPath || "/").replace(/^\/+/, "");
|
||||
const joined = path.normalize(path.join(root, clean));
|
||||
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
|
||||
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
|
||||
throw new Error("Path traversal blocked");
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const reqPath = searchParams.get("path") || "/";
|
||||
const abs = safeJoin(ROOT, reqPath);
|
||||
|
||||
const stat = await fs.stat(abs).catch(() => null);
|
||||
if (!stat) return NextResponse.json({ path: reqPath, items: [] });
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
const s = await fs.stat(abs);
|
||||
return NextResponse.json({
|
||||
path: reqPath,
|
||||
items: [{
|
||||
name: path.basename(abs),
|
||||
type: "file" as const,
|
||||
size: s.size,
|
||||
mtimeMs: s.mtimeMs,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(abs, { withFileTypes: true });
|
||||
const items = await Promise.all(entries.map(async (ent) => {
|
||||
const p = path.join(abs, ent.name);
|
||||
try {
|
||||
const s = await fs.stat(p);
|
||||
return {
|
||||
name: ent.name,
|
||||
type: ent.isDirectory() ? ("dir" as const) : ("file" as const),
|
||||
size: ent.isDirectory() ? 0 : s.size,
|
||||
mtimeMs: s.mtimeMs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
// Hide dotfiles by default; remove filter if you want to show them
|
||||
const visible = (items.filter(Boolean) as any[]).filter(i => !i.name.startsWith("."));
|
||||
|
||||
return NextResponse.json({ path: reqPath, items: visible });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: String(e?.message || e) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
142
app/app/api/files/raw/route.ts
Normal file
142
app/app/api/files/raw/route.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import fsp from "fs/promises";
|
||||
import path from "path";
|
||||
import mime from "mime";
|
||||
|
||||
const ROOT = (process.env.FILES_ROOT || "/files").trim();
|
||||
|
||||
// Only allow file types that are reasonably safe to preview inline.
|
||||
const ALLOWED_INLINE_EXTENSIONS = new Set([
|
||||
".pdf",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".txt",
|
||||
".md",
|
||||
".csv",
|
||||
".json",
|
||||
".log",
|
||||
]);
|
||||
|
||||
const BLOCKED_EXTENSIONS = new Set([
|
||||
".html",
|
||||
".htm",
|
||||
".svg",
|
||||
".xml",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".css",
|
||||
".ts",
|
||||
".tsx",
|
||||
".jsx",
|
||||
".map",
|
||||
".zip",
|
||||
".rar",
|
||||
".7z",
|
||||
".exe",
|
||||
".sh",
|
||||
".bat",
|
||||
".ps1",
|
||||
]);
|
||||
|
||||
function safeJoin(root: string, reqPath: string) {
|
||||
const clean = (reqPath || "").replace(/^\/+/, "");
|
||||
const joined = path.normalize(path.join(root, clean));
|
||||
const rootNorm = path.normalize(root.endsWith(path.sep) ? root : root + path.sep);
|
||||
|
||||
if (!joined.startsWith(rootNorm) && path.normalize(joined) !== path.normalize(root)) {
|
||||
throw new Error("Path traversal blocked");
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
function isPreviewAllowed(absPath: string, contentType: string) {
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
|
||||
if (BLOCKED_EXTENSIONS.has(ext)) return false;
|
||||
if (ALLOWED_INLINE_EXTENSIONS.has(ext)) return true;
|
||||
|
||||
// Extra guard: allow common image types and PDF by MIME too.
|
||||
if (contentType === "application/pdf") return true;
|
||||
if (contentType.startsWith("image/")) return true;
|
||||
if (contentType === "text/plain") return true;
|
||||
if (contentType === "text/markdown") return true;
|
||||
if (contentType === "text/csv") return true;
|
||||
if (contentType === "application/json") return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeFilename(name: string) {
|
||||
return name.replace(/[\r\n"]/g, "");
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const reqPath = searchParams.get("path") || "";
|
||||
|
||||
if (!reqPath) {
|
||||
return new NextResponse("Missing path", {
|
||||
status: 400,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const abs = safeJoin(ROOT, reqPath);
|
||||
const stat = await fsp.stat(abs).catch(() => null);
|
||||
|
||||
if (!stat || !stat.isFile()) {
|
||||
return new NextResponse("Not found", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = mime.getType(abs) || "application/octet-stream";
|
||||
|
||||
if (!isPreviewAllowed(abs, contentType)) {
|
||||
return new NextResponse("Preview not allowed for this file type", {
|
||||
status: 403,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(abs);
|
||||
const filename = safeFilename(path.basename(abs));
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `inline; filename="${filename}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("files/raw error:", e);
|
||||
|
||||
return new NextResponse("Bad request", {
|
||||
status: 400,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
22
app/app/api/me/route.ts
Normal file
22
app/app/api/me/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// app/api/me/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
import { dxGET } from "@/lib/directus";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
// Return only safe fields the UI needs
|
||||
const res = await dxGET<any>(
|
||||
"/users/me?fields=id,username,display_name,first_name,last_name,email",
|
||||
bearer
|
||||
);
|
||||
const me = res?.data ?? res;
|
||||
return NextResponse.json(me);
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? 500;
|
||||
return NextResponse.json({ error: e?.message || "Failed to load user" }, { status });
|
||||
}
|
||||
}
|
||||
48
app/app/api/my-settings/delete/route.ts
Normal file
48
app/app/api/my-settings/delete/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
import { dxGET, directusAdminFetch } from "@/lib/directus";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Deletes a settings row by submission_id, but only if the caller owns it.
|
||||
* Body: { collection: "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv", submission_id: string|number }
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { collection, submission_id } = await req.json();
|
||||
|
||||
if (
|
||||
!collection ||
|
||||
!["settings_co2gal", "settings_co2gan", "settings_fiber", "settings_uv"].includes(collection) ||
|
||||
(submission_id === undefined || submission_id === null || String(submission_id) === "")
|
||||
) {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Who is calling?
|
||||
const bearer = requireBearer(req);
|
||||
const me = await dxGET<any>("/users/me?fields=id", bearer);
|
||||
const meId = me?.data?.id ?? me?.id;
|
||||
if (!meId) return NextResponse.json({ error: "Unable to resolve user" }, { status: 401 });
|
||||
|
||||
// Find the item by submission_id using admin token (we cannot read id with user perms)
|
||||
const q = `/items/${collection}?filter[submission_id][_eq]=${encodeURIComponent(
|
||||
String(submission_id)
|
||||
)}&fields=id,owner.id&limit=1`;
|
||||
const found = await directusAdminFetch<any>(q);
|
||||
const row = Array.isArray(found?.data) ? found.data[0] : null;
|
||||
if (!row?.id) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const ownerId = row?.owner?.id ?? row?.owner;
|
||||
if (String(ownerId) !== String(meId)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete via admin
|
||||
await directusAdminFetch(`/items/${collection}/${row.id}`, { method: "DELETE" });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message || "Unknown error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
138
app/app/api/project/route.ts
Normal file
138
app/app/api/project/route.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// app/api/submit/project/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
uploadFile,
|
||||
createProjectRow,
|
||||
patchProject,
|
||||
bytesFromMB,
|
||||
dxGET,
|
||||
} from "@/lib/directus";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
|
||||
// Optional: tweak via env
|
||||
const MAX_MB = Number(process.env.FILE_MAX_MB || 25);
|
||||
const MAX_BYTES = bytesFromMB(MAX_MB);
|
||||
|
||||
// ultra-simple in-memory rate limiter (per server instance)
|
||||
const BUCKET = new Map<string, { c: number; resetAt: number }>();
|
||||
const WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW || 60) * 1000;
|
||||
const MAX_REQ = Number(process.env.RATE_LIMIT_MAX || 15);
|
||||
|
||||
function rateLimitOk(ip: string) {
|
||||
const now = Date.now();
|
||||
const rec = BUCKET.get(ip);
|
||||
if (!rec || now > rec.resetAt) {
|
||||
BUCKET.set(ip, { c: 1, resetAt: now + WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
if (rec.c >= MAX_REQ) return false;
|
||||
rec.c += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "0.0.0.0";
|
||||
if (!rateLimitOk(ip)) {
|
||||
return NextResponse.json({ error: "Rate limited" }, { status: 429 });
|
||||
}
|
||||
|
||||
// NEW: enforce user auth
|
||||
const bearer = requireBearer(req);
|
||||
|
||||
const ct = req.headers.get("content-type") || "";
|
||||
if (!ct.includes("multipart/form-data")) {
|
||||
return NextResponse.json({ error: "Expected multipart/form-data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const form = await req.formData();
|
||||
|
||||
const title = String(form.get("title") || "").trim();
|
||||
const category = String(form.get("category") || "").trim();
|
||||
const body = String(form.get("body") || form.get("description") || "").trim();
|
||||
|
||||
if (!title || !body) {
|
||||
return NextResponse.json({ error: "Missing required fields: title, body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Derive uploader from the authenticated user (ignore any spoofed form value)
|
||||
const me = await dxGET<any>("/users/me?fields=username,display_name,first_name,last_name,email", bearer);
|
||||
const uploader =
|
||||
me?.display_name ||
|
||||
me?.username ||
|
||||
[me?.first_name, me?.last_name].filter(Boolean).join(" ") ||
|
||||
me?.email ||
|
||||
"user";
|
||||
|
||||
// tags: allow comma-separated string or JSON array
|
||||
let tags: string[] = [];
|
||||
const rawTags = form.get("tags");
|
||||
if (typeof rawTags === "string" && rawTags.trim()) {
|
||||
try {
|
||||
const maybeArray = JSON.parse(rawTags);
|
||||
if (Array.isArray(maybeArray)) {
|
||||
tags = maybeArray.map((t) => String(t).trim()).filter(Boolean);
|
||||
} else {
|
||||
tags = rawTags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
tags = rawTags.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
const license = (form.get("license") && String(form.get("license")).trim()) || undefined;
|
||||
|
||||
// Upload hero image (single)
|
||||
const hero = form.get("image") as File | null;
|
||||
let p_image_id: string | undefined;
|
||||
if (hero && typeof hero === "object" && "size" in hero) {
|
||||
if (hero.size > MAX_BYTES) {
|
||||
return NextResponse.json({ error: `Hero image exceeds ${MAX_MB} MB` }, { status: 400 });
|
||||
}
|
||||
const up = await uploadFile(hero, (hero as File).name || "project-image", bearer);
|
||||
p_image_id = up.id;
|
||||
}
|
||||
|
||||
// Upload attachments (multiple)
|
||||
const fileBlobs = form.getAll("files").filter(Boolean) as File[];
|
||||
const attachIds: string[] = [];
|
||||
for (const f of fileBlobs.slice(0, 20)) {
|
||||
if (f.size > MAX_BYTES) {
|
||||
return NextResponse.json({ error: `One of the files exceeds ${MAX_MB} MB` }, { status: 400 });
|
||||
}
|
||||
const up = await uploadFile(f, (f as File).name || "attachment", bearer);
|
||||
attachIds.push(up.id);
|
||||
}
|
||||
|
||||
// 1) Create the project row (as current user)
|
||||
const { data: created } = await createProjectRow(
|
||||
{
|
||||
title,
|
||||
body,
|
||||
uploader, // server-derived
|
||||
category,
|
||||
tags,
|
||||
...(license ? { license } : {}),
|
||||
status: "pending",
|
||||
submitted_via: "makearmy-app",
|
||||
submitted_at: new Date().toISOString(),
|
||||
},
|
||||
bearer
|
||||
);
|
||||
|
||||
// 2) Patch hero image + M2M attachments in one go
|
||||
const patch: Record<string, any> = {};
|
||||
if (p_image_id) patch.p_image = p_image_id;
|
||||
if (attachIds.length) patch.p_files = attachIds.map((id) => ({ directus_files_id: id }));
|
||||
|
||||
if (Object.keys(patch).length) {
|
||||
await patchProject(created.id, patch, bearer);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, id: created.id });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err?.message || "Unknown error" }, { status: err?.status ?? 500 });
|
||||
}
|
||||
}
|
||||
122
app/app/api/rigs/route.ts
Normal file
122
app/app/api/rigs/route.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// app/api/rigs/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import { dxGET, dxPOST, dxDELETE } from "@/lib/directus";
|
||||
import { requireBearer } from "@/app/api/_lib/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function bad(msg: string, code = 400) {
|
||||
return NextResponse.json({ error: msg }, { status: code });
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
|
||||
const q = new URL(req.url).searchParams;
|
||||
const limit = Math.min(parseInt(q.get("limit") || "50", 10), 100);
|
||||
|
||||
const fields = [
|
||||
"id",
|
||||
"name",
|
||||
"notes",
|
||||
"rig_type.id",
|
||||
"rig_type.name",
|
||||
"laser_source.submission_id",
|
||||
"laser_source.make",
|
||||
"laser_source.model",
|
||||
"laser_scan_lens.id",
|
||||
"laser_scan_lens.field_size",
|
||||
"laser_scan_lens.focal_length",
|
||||
"laser_focus_lens.id",
|
||||
"laser_focus_lens.name",
|
||||
"laser_scan_lens_apt.id", // NEW
|
||||
"laser_scan_lens_apt.name", // NEW
|
||||
"laser_scan_lens_exp.id", // NEW
|
||||
"laser_scan_lens_exp.name", // NEW
|
||||
"laser_software.id",
|
||||
"laser_software.name",
|
||||
"date_created",
|
||||
"date_updated",
|
||||
].join(",");
|
||||
|
||||
const path =
|
||||
`/items/user_rigs?fields=${encodeURIComponent(fields)}` +
|
||||
`&sort=-date_updated` +
|
||||
`&limit=${limit}`;
|
||||
|
||||
const rows = await dxGET<any[]>(path, bearer);
|
||||
return NextResponse.json(rows ?? []);
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Failed to load rigs", e?.status || 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const body = await req.json().catch(() => ({}));
|
||||
|
||||
const name = (body?.name || "").trim();
|
||||
const rig_type = body?.rig_type; // id
|
||||
const laser_source = body?.laser_source; // submission_id
|
||||
const laser_scan_lens = body?.laser_scan_lens || null;
|
||||
const laser_focus_lens = body?.laser_focus_lens || null;
|
||||
const laser_scan_lens_apt = body?.laser_scan_lens_apt || null; // NEW
|
||||
const laser_scan_lens_exp = body?.laser_scan_lens_exp || null; // NEW
|
||||
const laser_software = body?.laser_software || null;
|
||||
const notes = (body?.notes || "").trim();
|
||||
|
||||
if (!name) return bad("Missing: name");
|
||||
if (!rig_type) return bad("Missing: rig_type");
|
||||
if (!laser_source) return bad("Missing: laser_source");
|
||||
|
||||
const me = await dxGET<{ id: string }>("/users/me?fields=id", bearer);
|
||||
|
||||
const payload: any = {
|
||||
owner: me.id,
|
||||
name,
|
||||
rig_type,
|
||||
laser_source,
|
||||
laser_scan_lens,
|
||||
laser_focus_lens,
|
||||
laser_scan_lens_apt, // NEW
|
||||
laser_scan_lens_exp, // NEW
|
||||
laser_software,
|
||||
notes,
|
||||
};
|
||||
|
||||
const res = await dxPOST<{ data: { id: string } }>(
|
||||
"/items/user_rigs",
|
||||
bearer,
|
||||
payload
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true, id: String(res?.data?.id) });
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Failed to create rig", e?.status || 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const bearer = requireBearer(req);
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
if (!id) return bad("Missing: id");
|
||||
|
||||
const me = await dxGET<{ id: string }>("/users/me?fields=id", bearer);
|
||||
const rig = await dxGET<any>(
|
||||
`/items/user_rigs/${encodeURIComponent(id)}?fields=id,owner`,
|
||||
bearer
|
||||
);
|
||||
if (!rig || String(rig.owner) !== String(me.id)) {
|
||||
return bad("Not your rig", 403);
|
||||
}
|
||||
|
||||
await dxDELETE(`/items/user_rigs/${encodeURIComponent(id)}`, bearer);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: any) {
|
||||
return bad(e?.message || "Failed to delete rig", e?.status || 500);
|
||||
}
|
||||
}
|
||||
306
app/app/api/settings/route.ts
Normal file
306
app/app/api/settings/route.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// app/api/settings/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Fresh, minimal Directus client (no external helpers).
|
||||
* - Upload assets to /files with multipart/form-data.
|
||||
* - Create and update records via /items/{collection}.
|
||||
* - Auth is via user cookie (ma_at) or a submit token (DIRECTUS_TOKEN_SUBMIT).
|
||||
*/
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Env
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const DX = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
const SUBMIT_TOKEN = process.env.DIRECTUS_TOKEN_SUBMIT || "";
|
||||
|
||||
// Folder IDs from env (data sheet says fixed, not browsable)
|
||||
const FOLDERS = {
|
||||
settings_co2gal: {
|
||||
photo: process.env.DX_FOLDER_GALVO_PHOTOS || "",
|
||||
screen: process.env.DX_FOLDER_GALVO_SCREENS || "",
|
||||
},
|
||||
settings_co2gan: {
|
||||
photo: process.env.DX_FOLDER_GANTRY_PHOTOS || "",
|
||||
screen: process.env.DX_FOLDER_GANTRY_SCREENS || "",
|
||||
},
|
||||
settings_fiber: {
|
||||
photo: process.env.DX_FOLDER_FIBER_PHOTOS || "",
|
||||
screen: process.env.DX_FOLDER_FIBER_SCREENS || "",
|
||||
},
|
||||
settings_uv: {
|
||||
photo: process.env.DX_FOLDER_UV_PHOTOS || "",
|
||||
screen: process.env.DX_FOLDER_UV_SCREENS || "",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type Target = "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv";
|
||||
|
||||
function bearerFrom(req: Request) {
|
||||
// Prefer user cookie (session) else fall back to submit token for server ops.
|
||||
const cookie = req.headers.get("cookie") || "";
|
||||
const m = cookie.match(/(?:^|;\s*)ma_at=([^;]+)/);
|
||||
const at = m?.[1];
|
||||
return at ? `Bearer ${at}` : SUBMIT_TOKEN ? `Bearer ${SUBMIT_TOKEN}` : "";
|
||||
}
|
||||
|
||||
async function dxUpload(file: File, folderId: string, bearer: string) {
|
||||
const form = new FormData();
|
||||
form.set("file", file, file.name || "upload");
|
||||
if (folderId) form.set("folder", folderId);
|
||||
|
||||
const res = await fetch(`${DX}/files`, {
|
||||
method: "POST",
|
||||
headers: bearer ? { authorization: bearer } : undefined,
|
||||
body: form,
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = j?.errors?.[0]?.message || `Directus /files failed (HTTP ${res.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return j?.data?.id as string;
|
||||
}
|
||||
|
||||
async function dxCreate(target: Target, data: any, bearer: string) {
|
||||
const res = await fetch(`${DX}/items/${target}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(bearer ? { authorization: bearer } : {}),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = j?.errors?.[0]?.message || `Directus create failed (HTTP ${res.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return j?.data;
|
||||
}
|
||||
|
||||
async function dxUpdate(target: Target, pk: string | number, data: any, bearer: string) {
|
||||
const res = await fetch(`${DX}/items/${target}/${encodeURIComponent(String(pk))}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(bearer ? { authorization: bearer } : {}),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = j?.errors?.[0]?.message || `Directus update failed (HTTP ${res.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return j?.data;
|
||||
}
|
||||
|
||||
// Guard numeric
|
||||
const num = (v: any) => (v === "" || v == null || Number.isNaN(Number(v)) ? null : Number(v));
|
||||
// Guard bool
|
||||
const bool = (v: any) => !!v;
|
||||
// Guard id string
|
||||
const idOrNull = (v: any) => (v === "" || v == null ? null : String(v));
|
||||
|
||||
type ReadResult = {
|
||||
mode: "json" | "multipart";
|
||||
body: any;
|
||||
photoFile: File | null;
|
||||
screenFile: File | null;
|
||||
};
|
||||
|
||||
async function readJsonOrMultipart(req: Request): Promise<ReadResult> {
|
||||
const ct = (req.headers.get("content-type") || "").toLowerCase();
|
||||
if (ct.includes("multipart/form-data")) {
|
||||
const form = await (req as any).formData();
|
||||
const payloadRaw = String(form.get("payload") ?? "{}");
|
||||
let body: any = {};
|
||||
try {
|
||||
body = JSON.parse(payloadRaw);
|
||||
} catch {
|
||||
throw new Error("Invalid JSON in 'payload'");
|
||||
}
|
||||
const p = form.get("photo");
|
||||
const s = form.get("screen");
|
||||
return {
|
||||
mode: "multipart",
|
||||
body,
|
||||
photoFile: p instanceof File && p.size > 0 ? (p as File) : null,
|
||||
screenFile: s instanceof File && s.size > 0 ? (s as File) : null,
|
||||
};
|
||||
}
|
||||
const body = await (req as any).json().catch(() => ({}));
|
||||
return { mode: "json", body, photoFile: null, screenFile: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: create or update a settings_* record
|
||||
* Body (JSON or multipart with { payload }):
|
||||
* {
|
||||
* target: "settings_co2gal" | ...,
|
||||
* mode?: "edit",
|
||||
* submission_id?: string|number,
|
||||
* // fields per data sheet (CO2 Galvo shown)
|
||||
* setting_title: string (required),
|
||||
* setting_notes?: string,
|
||||
* photo?: string (asset id) // if not provided in create, require file in multipart
|
||||
* screen?: string (asset id) // optional
|
||||
* // Material & Rig / Optics
|
||||
* mat: string (id),
|
||||
* mat_coat: string (id),
|
||||
* mat_color: string (id),
|
||||
* mat_opacity: string (id),
|
||||
* mat_thickness?: number,
|
||||
* laser_soft: string (id),
|
||||
* source: string (submission_id of laser_source),
|
||||
* lens: string (id),
|
||||
* focus?: number,
|
||||
* // CO2 Galvo Options (part of Rig & Optics per sheet)
|
||||
* lens_conf: string (id),
|
||||
* lens_apt: string (id),
|
||||
* lens_exp: string (id),
|
||||
* repeat_all?: number,
|
||||
* // Repeaters
|
||||
* fill_settings?: Array<...>,
|
||||
* line_settings?: Array<...>,
|
||||
* raster_settings?: Array<...>
|
||||
* }
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { body, photoFile, screenFile } = await readJsonOrMultipart(req);
|
||||
|
||||
const target = String(body?.target || "") as Target;
|
||||
if (!target || !FOLDERS[target]) {
|
||||
return NextResponse.json({ error: "Invalid or missing target." }, { status: 400 });
|
||||
}
|
||||
|
||||
const isEdit = body?.mode === "edit";
|
||||
const pk = isEdit ? body?.submission_id : null;
|
||||
|
||||
// Upload assets if files are present
|
||||
const bearer = bearerFrom(req);
|
||||
|
||||
const folderCfg = FOLDERS[target];
|
||||
let photoId = idOrNull(body.photo);
|
||||
let screenId = idOrNull(body.screen);
|
||||
|
||||
if (photoFile) {
|
||||
if (!folderCfg.photo) throw new Error("Photo folder not configured.");
|
||||
photoId = await dxUpload(photoFile, folderCfg.photo, bearer);
|
||||
}
|
||||
if (screenFile) {
|
||||
if (!folderCfg.screen) throw new Error("Screen folder not configured.");
|
||||
screenId = await dxUpload(screenFile, folderCfg.screen, bearer);
|
||||
}
|
||||
|
||||
// Enforce requireds (data sheet: title + result photo on create)
|
||||
if (!body.setting_title || String(body.setting_title).trim() === "") {
|
||||
return NextResponse.json({ error: "Missing required: setting_title" }, { status: 400 });
|
||||
}
|
||||
if (!isEdit && !photoId) {
|
||||
return NextResponse.json({ error: "Result photo is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build Directus payload strictly as the collection expects (ids + arrays)
|
||||
const payload: any = {
|
||||
setting_title: String(body.setting_title),
|
||||
setting_notes: String(body.setting_notes || ""),
|
||||
// Assets
|
||||
...(photoId ? { photo: photoId } : {}),
|
||||
...(screenId ? { screen: screenId } : {}),
|
||||
// Material/Rig & Optics (M2O ids)
|
||||
mat: idOrNull(body.mat),
|
||||
mat_coat: idOrNull(body.mat_coat),
|
||||
mat_color: idOrNull(body.mat_color),
|
||||
mat_opacity: idOrNull(body.mat_opacity),
|
||||
mat_thickness: num(body.mat_thickness),
|
||||
laser_soft: idOrNull(body.laser_soft),
|
||||
source: idOrNull(body.source), // note: this is submission_id for laser_source; schema should be configured accordingly
|
||||
lens: idOrNull(body.lens),
|
||||
focus: num(body.focus),
|
||||
// CO2 Galvo option triplet (Rig & Optics)
|
||||
lens_conf: idOrNull(body.lens_conf),
|
||||
lens_apt: idOrNull(body.lens_apt),
|
||||
lens_exp: idOrNull(body.lens_exp),
|
||||
repeat_all: num(body.repeat_all),
|
||||
// Repeaters (arrays of plain objects)
|
||||
fill_settings: Array.isArray(body.fill_settings) ? body.fill_settings.map(mapFill) : [],
|
||||
line_settings: Array.isArray(body.line_settings) ? body.line_settings.map(mapLine) : [],
|
||||
raster_settings: Array.isArray(body.raster_settings) ? body.raster_settings.map(mapRaster) : [],
|
||||
};
|
||||
|
||||
let saved;
|
||||
if (isEdit) {
|
||||
if (!pk) return NextResponse.json({ error: "Missing submission_id for edit mode." }, { status: 400 });
|
||||
saved = await dxUpdate(target, pk, payload, bearer);
|
||||
} else {
|
||||
saved = await dxCreate(target, payload, bearer);
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: saved?.submission_id ?? saved?.id ?? null, data: saved }, { status: 200 });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e?.message || "Failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Mappers (ensure numeric/bool normalization per sheet)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function mapFill(r: any) {
|
||||
return {
|
||||
name: r?.name || "",
|
||||
type: (r?.type || "").toString(), // uni|bi|offset
|
||||
power: num(r?.power),
|
||||
speed: num(r?.speed),
|
||||
interval: num(r?.interval),
|
||||
pass: num(r?.pass),
|
||||
frequency: num(r?.frequency),
|
||||
pulse: num(r?.pulse),
|
||||
angle: num(r?.angle),
|
||||
auto: bool(r?.auto),
|
||||
increment: num(r?.increment),
|
||||
cross: bool(r?.cross),
|
||||
flood: bool(r?.flood),
|
||||
air: bool(r?.air),
|
||||
};
|
||||
}
|
||||
function mapLine(r: any) {
|
||||
return {
|
||||
name: r?.name || "",
|
||||
power: num(r?.power),
|
||||
speed: num(r?.speed),
|
||||
perf: bool(r?.perf),
|
||||
cut: bool(r?.cut),
|
||||
skip: bool(r?.skip),
|
||||
pass: num(r?.pass),
|
||||
air: bool(r?.air),
|
||||
frequency: num(r?.frequency),
|
||||
pulse: num(r?.pulse),
|
||||
wobble: bool(r?.wobble),
|
||||
step: num(r?.step),
|
||||
size: num(r?.size),
|
||||
};
|
||||
}
|
||||
function mapRaster(r: any) {
|
||||
return {
|
||||
name: r?.name || "",
|
||||
type: (r?.type || "").toString(), // uni|bi|offset
|
||||
dither: (r?.dither || "").toString(), // threshold|ordered|...
|
||||
halftone_cell: num(r?.halftone_cell),
|
||||
halftone_angle: num(r?.halftone_angle),
|
||||
inversion: bool(r?.inversion),
|
||||
interval: num(r?.interval),
|
||||
dot: num(r?.dot),
|
||||
power: num(r?.power),
|
||||
speed: num(r?.speed),
|
||||
pass: num(r?.pass),
|
||||
air: bool(r?.air),
|
||||
frequency: num(r?.frequency),
|
||||
pulse: num(r?.pulse),
|
||||
cross: bool(r?.cross),
|
||||
};
|
||||
}
|
||||
36
app/app/api/support/badges/route.ts
Normal file
36
app/app/api/support/badges/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// /app/api/support/badges/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { fetchMembershipBadges } from "@/lib/memberships";
|
||||
|
||||
// Replace this with your real auth lookup if/when you wire it in
|
||||
async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> {
|
||||
const uid = req.headers.get("x-user-id") || undefined;
|
||||
const email = req.headers.get("x-user-email") || undefined;
|
||||
return uid || email ? { id: uid, email } : null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const me = await getCurrentUser(req);
|
||||
|
||||
// Accept explicit query params so the component can work without special headers
|
||||
const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined;
|
||||
const userIdParam = (req.nextUrl.searchParams.get("userId") || "").trim() || undefined;
|
||||
|
||||
if (!emailParam && !userIdParam && !me?.id && !me?.email) {
|
||||
return NextResponse.json({ error: "Provide email or userId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const badges = await fetchMembershipBadges({
|
||||
userId: userIdParam || me?.id,
|
||||
email: emailParam || me?.email,
|
||||
});
|
||||
|
||||
return NextResponse.json({ badges });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "badges_fetch_failed", detail: String(e?.message || e) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/app/api/support/kofi/claim/start/route.ts
Normal file
126
app/app/api/support/kofi/claim/start/route.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// app/api/support/kofi/claim/start/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
const APP_ORIGIN = process.env.APP_ORIGIN || "https://makearmy.io"; // your site base URL
|
||||
|
||||
// SMTP envs
|
||||
const SMTP_HOST = process.env.SMTP_HOST || "";
|
||||
const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 587;
|
||||
const SMTP_USER = process.env.SMTP_USER || "";
|
||||
const SMTP_PASS = process.env.SMTP_PASS || "";
|
||||
const SMTP_SECURE = process.env.SMTP_SECURE === "true";
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support <no-reply@makearmy.io>";
|
||||
|
||||
// TODO: replace with your actual auth/session resolver
|
||||
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
|
||||
const uid = req.headers.get("x-user-id");
|
||||
return uid && uid.trim() ? uid : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Basic SMTP sanity check (fail fast)
|
||||
if (!SMTP_HOST || !SMTP_PORT || !EMAIL_FROM) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "email_not_configured", detail: "SMTP_HOST/PORT/EMAIL_FROM must be set" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = await getCurrentUserId(req);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let email = "";
|
||||
try {
|
||||
const body = await req.json();
|
||||
email = String(body?.email || "").trim().toLowerCase();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 });
|
||||
}
|
||||
if (!email) {
|
||||
return NextResponse.json({ ok: false, error: "missing_email" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1) find Ko-fi membership by email
|
||||
const filter = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
_and: [{ provider: { _eq: "kofi" } }, { email: { _eq: email } }],
|
||||
})
|
||||
);
|
||||
|
||||
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
|
||||
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
return NextResponse.json({ ok: false, error: "directus_read_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
const json = await res.json();
|
||||
const rec = json?.data?.[0];
|
||||
if (!rec) {
|
||||
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
|
||||
}
|
||||
if (rec.app_user) {
|
||||
// Already linked
|
||||
return NextResponse.json({ ok: true, alreadyLinked: true });
|
||||
}
|
||||
|
||||
// 2) create a one-time token
|
||||
const token = crypto.randomBytes(24).toString("base64url");
|
||||
const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
|
||||
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
claim_token: token,
|
||||
claim_expires_at: expires.toISOString(),
|
||||
claim_user_id: userId,
|
||||
}),
|
||||
});
|
||||
if (!patch.ok) {
|
||||
const t = await patch.text().catch(() => "");
|
||||
return NextResponse.json({ ok: false, error: "directus_write_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
|
||||
// 3) send verification email
|
||||
const verifyUrl = `${APP_ORIGIN}/api/support/kofi/claim/verify?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST, // e.g. "mail.arrmail.net"
|
||||
port: SMTP_PORT, // 465 in your case
|
||||
secure: SMTP_SECURE, // true for 465, false for 587 STARTTLS
|
||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
// If your server uses a self-signed certificate, uncomment the next line.
|
||||
// Prefer installing a valid cert instead.
|
||||
// tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: EMAIL_FROM, // "MakeArmy Support <noreply@makearmy.io>" recommended
|
||||
to: email,
|
||||
subject: "Verify Ko-fi link to your MakeArmy account",
|
||||
text: `Tap to verify your Ko-fi link: ${verifyUrl}\nThis link expires in 15 minutes.`,
|
||||
html: `<p>Tap to verify your Ko-fi link:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>This link expires in 15 minutes.</p>`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "email_send_failed", detail: String(e?.message || e) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
53
app/app/api/support/kofi/claim/verify/route.ts
Normal file
53
app/app/api/support/kofi/claim/verify/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// /app/api/support/kofi/claim/verify/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
// Default redirects; can be overridden by env
|
||||
const SUCCESS_REDIRECT = process.env.KOFI_LINK_SUCCESS_URL || "/portal/account?linked=kofi&ok=1";
|
||||
const FAIL_REDIRECT = process.env.KOFI_LINK_FAIL_URL || "/portal/account?linked=kofi&error=1";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get("token") || "";
|
||||
const to = (path: string) => NextResponse.redirect(new URL(path, req.url));
|
||||
|
||||
if (!token) return to(FAIL_REDIRECT);
|
||||
|
||||
// Find the claim row by token
|
||||
const filter = encodeURIComponent(JSON.stringify({ claim_token: { _eq: token } }));
|
||||
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
|
||||
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return to(FAIL_REDIRECT);
|
||||
|
||||
const json = await res.json().catch(() => ({} as any));
|
||||
const rec = json?.data?.[0];
|
||||
if (!rec) return to(FAIL_REDIRECT);
|
||||
|
||||
// Validate expiry and sanity
|
||||
const exp = rec.claim_expires_at ? new Date(rec.claim_expires_at).getTime() : 0;
|
||||
if (!exp || Date.now() > exp) return to(FAIL_REDIRECT);
|
||||
if (!rec.claim_user_id) return to(FAIL_REDIRECT);
|
||||
|
||||
// Finalize: set app_user to claim_user_id; clear claim fields
|
||||
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
app_user: rec.claim_user_id,
|
||||
claim_token: null,
|
||||
claim_expires_at: null,
|
||||
claim_user_id: null,
|
||||
last_event_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!patch.ok) return to(FAIL_REDIRECT);
|
||||
return to(SUCCESS_REDIRECT);
|
||||
}
|
||||
61
app/app/api/support/kofi/unlink/route.ts
Normal file
61
app/app/api/support/kofi/unlink/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// app/api/support/kofi/unlink/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
// Replace with your real auth/session
|
||||
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
|
||||
const uid = req.headers.get("x-user-id");
|
||||
return uid && uid.trim() ? uid : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const userId = await getCurrentUserId(req);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Find linked Ko-fi rows
|
||||
const filter = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
_and: [{ provider: { _eq: "kofi" } }, { app_user: { _eq: userId } }],
|
||||
})
|
||||
);
|
||||
|
||||
const list = await fetch(
|
||||
`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=500`,
|
||||
{ headers: { Authorization: `Bearer ${BOT_TOKEN}` }, cache: "no-store" }
|
||||
);
|
||||
if (!list.ok) {
|
||||
const t = await list.text().catch(() => "");
|
||||
return NextResponse.json({ error: "directus_read_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
|
||||
const rows = (await list.json()).data || [];
|
||||
if (!rows.length) return NextResponse.json({ ok: true, changed: 0 });
|
||||
|
||||
// Batch PATCH: clear app_user
|
||||
const body = rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
app_user: null,
|
||||
last_event_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
return NextResponse.json({ error: "directus_write_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, changed: rows.length });
|
||||
}
|
||||
186
app/app/api/webhooks/kofi/route.ts
Normal file
186
app/app/api/webhooks/kofi/route.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// app/api/webhooks/kofi/route.ts
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const VERIFY = process.env.KOFI_VERIFY_TOKEN || "";
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || "";
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
// TEMP healthcheck: prove code + envs are live (remove after testing)
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
env: {
|
||||
hasVerifyToken: Boolean(VERIFY),
|
||||
hasDirectusUrl: Boolean(DIRECTUS),
|
||||
hasBotToken: Boolean(BOT_TOKEN),
|
||||
collection: COLLECTION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type KofiPayload = {
|
||||
verification_token: string;
|
||||
message_id: string;
|
||||
timestamp?: string;
|
||||
type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string;
|
||||
is_public?: boolean;
|
||||
from_name?: string;
|
||||
message?: string | null;
|
||||
amount?: string;
|
||||
url?: string;
|
||||
email?: string | null;
|
||||
currency?: string;
|
||||
is_subscription_payment?: boolean;
|
||||
is_first_subscription_payment?: boolean;
|
||||
kofi_transaction_id?: string;
|
||||
shop_items?: Array<{ direct_link_code: string; variation_name?: string; quantity?: number }> | null;
|
||||
tier_name?: string | null;
|
||||
shipping?: Record<string, unknown> | null;
|
||||
discord_username?: string | null;
|
||||
discord_userid?: string | null;
|
||||
};
|
||||
|
||||
function log(o: unknown) {
|
||||
process.stdout.write(`[kofi] ${JSON.stringify(o)}\n`);
|
||||
}
|
||||
function json(res: unknown, status = 200) {
|
||||
return NextResponse.json(res, { status });
|
||||
}
|
||||
function addOneMonthPlusOneDay(d: Date) {
|
||||
const nd = new Date(d);
|
||||
const m = nd.getMonth();
|
||||
nd.setMonth(m + 1);
|
||||
if (nd.getMonth() !== ((m + 1) % 12)) nd.setDate(0);
|
||||
nd.setDate(nd.getDate() + 1);
|
||||
return nd;
|
||||
}
|
||||
function safeStringify(v: unknown) {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return JSON.stringify({ _unstringifiable: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const ct = req.headers.get("content-type") || "";
|
||||
log({ step: "recv", ct });
|
||||
|
||||
if (!VERIFY || !DIRECTUS || !BOT_TOKEN) {
|
||||
log({ step: "env-missing", hasVerify: !!VERIFY, hasDirectus: !!DIRECTUS, hasBot: !!BOT_TOKEN });
|
||||
return json({ ok: false, error: "server_misconfigured" }, 500);
|
||||
}
|
||||
|
||||
let data: KofiPayload | null = null;
|
||||
try {
|
||||
if (ct.includes("application/x-www-form-urlencoded")) {
|
||||
const form = await req.formData();
|
||||
const raw = form.get("data");
|
||||
if (typeof raw !== "string") throw new Error("missing data field");
|
||||
data = JSON.parse(raw) as KofiPayload;
|
||||
log({ step: "parsed-formdata" });
|
||||
} else {
|
||||
// allow JSON for local tests
|
||||
data = (await req.json()) as KofiPayload;
|
||||
log({ step: "parsed-json" });
|
||||
}
|
||||
} catch (e: any) {
|
||||
log({ step: "parse-error", err: String(e?.message || e) });
|
||||
return json({ ok: false, error: "invalid_payload" }, 400);
|
||||
}
|
||||
|
||||
if (!data?.verification_token || data.verification_token !== VERIFY) {
|
||||
log({ step: "verify-fail", got: data?.verification_token, expectSet: Boolean(VERIFY) });
|
||||
return json({ ok: false, error: "unauthorized" }, 401);
|
||||
}
|
||||
log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment });
|
||||
|
||||
// Compute values
|
||||
const t = (data.type || "").toLowerCase();
|
||||
const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription");
|
||||
const status = isRecurring ? "active" : "one_time";
|
||||
|
||||
const ts = data.timestamp ? new Date(data.timestamp) : null;
|
||||
const emailId = (data.email && data.email.trim().toLowerCase()) || null;
|
||||
const fallbackId =
|
||||
(data.discord_userid && data.discord_userid.trim()) ||
|
||||
(data.from_name && data.from_name.trim()) ||
|
||||
"unknown";
|
||||
|
||||
const filter = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
_and: [{ provider: { _eq: "kofi" } }],
|
||||
_or: [
|
||||
...(emailId ? [{ email: { _eq: emailId } }, { external_user_id: { _eq: emailId } }] : []),
|
||||
{ external_user_id: { _eq: fallbackId } },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Read existing
|
||||
const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
|
||||
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!existingRes.ok) {
|
||||
const body = await existingRes.text().catch(() => "");
|
||||
log({ step: "directus-read-fail", status: existingRes.status, body });
|
||||
return json({ ok: false, error: "directus_error_read" }, 500);
|
||||
}
|
||||
const existingJson = await existingRes.json().catch(() => ({} as any));
|
||||
const existing = existingJson?.data?.[0] ?? null;
|
||||
log({ step: "directus-read-ok", hasExisting: Boolean(existing) });
|
||||
|
||||
const external_user_id = emailId || (existing?.external_user_id ?? fallbackId);
|
||||
const started_at =
|
||||
isRecurring && ts
|
||||
? existing?.started_at
|
||||
? existing.started_at
|
||||
: ts.toISOString()
|
||||
: existing?.started_at ?? null;
|
||||
const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts).toISOString() : null;
|
||||
|
||||
const record: Record<string, any> = {
|
||||
provider: "kofi",
|
||||
external_user_id,
|
||||
email: emailId || data.email || null,
|
||||
username: data.from_name || null,
|
||||
status,
|
||||
tier: data.tier_name || null,
|
||||
started_at,
|
||||
renews_at,
|
||||
last_event_at: new Date().toISOString(),
|
||||
raw: safeStringify(data),
|
||||
};
|
||||
|
||||
const url = existing?.id
|
||||
? `${DIRECTUS}/items/${COLLECTION}/${existing.id}`
|
||||
: `${DIRECTUS}/items/${COLLECTION}`;
|
||||
const method = existing?.id ? "PATCH" : "POST";
|
||||
|
||||
// Avoid clobbering start/renews when payload has no sub timestamp
|
||||
if (existing?.id && (!isRecurring || !ts)) {
|
||||
delete record.started_at;
|
||||
delete record.renews_at;
|
||||
}
|
||||
|
||||
const writeRes = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${BOT_TOKEN}` },
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
|
||||
if (!writeRes.ok) {
|
||||
const body = await writeRes.text().catch(() => "");
|
||||
log({ step: "directus-write-fail", status: writeRes.status, body });
|
||||
return json({ ok: false, error: "directus_error_write" }, 500);
|
||||
}
|
||||
const written = await writeRes.json().catch(() => ({} as any));
|
||||
log({ step: "directus-write-ok", method, id: written?.data?.id || existing?.id || "?" });
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
30
app/app/auth/sign-in/page.tsx
Normal file
30
app/app/auth/sign-in/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// app/auth/sign-in/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignIn from "./sign-in";
|
||||
import { isJwtValid } from "@/lib/jwt";
|
||||
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const sp = searchParams ?? {};
|
||||
|
||||
const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next;
|
||||
const nextPath =
|
||||
nextParam && String(nextParam).startsWith("/") ? String(nextParam) : "/portal";
|
||||
|
||||
const reauthParam = Array.isArray(sp.reauth) ? sp.reauth[0] : sp.reauth;
|
||||
const forceParam = Array.isArray(sp.force) ? sp.force[0] : sp.force;
|
||||
const reauth = reauthParam === "1" || forceParam === "1";
|
||||
|
||||
// If reauth is requested, always render the form (no redirect).
|
||||
if (!reauth) {
|
||||
const ck = await cookies();
|
||||
const at = ck.get("ma_at")?.value;
|
||||
if (isJwtValid(at)) redirect("/portal");
|
||||
}
|
||||
|
||||
return <SignIn nextPath={nextPath} reauth={reauth} />;
|
||||
}
|
||||
138
app/app/auth/sign-in/sign-in.tsx
Normal file
138
app/app/auth/sign-in/sign-in.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// app/auth/sign-in/sign-in.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = { nextPath?: string; reauth?: boolean };
|
||||
|
||||
export default function SignIn({ nextPath = "/portal", reauth = false }: Props) {
|
||||
const router = useRouter();
|
||||
const [identifier, setIdentifier] = useState(""); // email OR username
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShow] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const body = {
|
||||
identifier: identifier.trim(),
|
||||
password,
|
||||
};
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const txt = await res.text();
|
||||
let j: any = null;
|
||||
try {
|
||||
j = txt ? JSON.parse(txt) : null;
|
||||
} catch {}
|
||||
|
||||
if (!res.ok) {
|
||||
// surface server-provided message when available
|
||||
const msg =
|
||||
j?.error || j?.message || (res.status === 401 ? "Invalid credentials." : `Sign-in failed (${res.status})`);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// If this sign-in is being used as a re-auth step, set a short-lived 'recent auth' marker.
|
||||
if (reauth && typeof document !== "undefined") {
|
||||
document.cookie = `ma_ra=1; Max-Age=300; Path=/; SameSite=Lax${
|
||||
process.env.NODE_ENV === "production" ? "; Secure" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
// Land where caller requested
|
||||
router.replace(nextPath);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign in.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[identifier, password, nextPath, router, reauth]
|
||||
);
|
||||
|
||||
const createHref = `/auth/sign-up?next=${encodeURIComponent(nextPath)}`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-lg border p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="mb-1 text-2xl font-semibold">{reauth ? "Re-authenticate" : "Sign In"}</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
{reauth ? "Please sign in again to continue." : <>Use your email <em>or</em> username with your password.</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email or Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com or your-handle"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Signing in…" : reauth ? "Re-authenticate" : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Always show a sign-up path, even on reauth, to avoid dead-ends for first-time visitors */}
|
||||
<div className="mt-2 text-center text-sm">
|
||||
<span className="opacity-70">{reauth ? "Don't have an account?" : "New here?"}</span>{" "}
|
||||
<a className="underline" href={createHref}>
|
||||
Create an account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/app/auth/sign-up/page.tsx
Normal file
22
app/app/auth/sign-up/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// app/auth/sign-up/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignUp from "./sign-up";
|
||||
import { isJwtValid } from "@/lib/jwt";
|
||||
|
||||
export default async function SignUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const ck = await cookies();
|
||||
const at = ck.get("ma_at")?.value;
|
||||
if (isJwtValid(at)) redirect("/portal");
|
||||
|
||||
const sp = searchParams ?? {};
|
||||
const nextParam = Array.isArray(sp.next) ? sp.next[0] : sp.next;
|
||||
const nextPath =
|
||||
nextParam && String(nextParam).startsWith("/") ? String(nextParam) : "/portal";
|
||||
|
||||
return <SignUp nextPath={nextPath} />;
|
||||
}
|
||||
175
app/app/auth/sign-up/sign-up.tsx
Normal file
175
app/app/auth/sign-up/sign-up.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
// app/auth/sign-up/sign-up.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = { nextPath?: string };
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export default function SignUp({ nextPath = "/portal" }: Props) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
|
||||
const em = email.trim().toLowerCase();
|
||||
const un = username.trim();
|
||||
|
||||
if (!em || !un || !password || !confirmPassword) {
|
||||
setErr("All fields are required.");
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(em)) {
|
||||
setErr("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setErr("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setErr("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ email: em, username: un, password, confirmPassword }),
|
||||
});
|
||||
|
||||
const txt = await res.text();
|
||||
let j: any = null;
|
||||
try {
|
||||
j = txt ? JSON.parse(txt) : null;
|
||||
} catch {
|
||||
j = null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
j?.error ||
|
||||
j?.message ||
|
||||
(res.status === 409 ? "Email or username already in use." : `Sign-up failed (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
router.replace(nextPath);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unable to sign up.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[email, username, password, confirmPassword, nextPath, router]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded-lg border p-6">
|
||||
<h1 className="mb-1 text-2xl font-semibold">Create Account</h1>
|
||||
<p className="mb-6 text-sm opacity-70">Join MakeArmy to manage rigs, settings, and projects.</p>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="your-handle"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-70 hover:opacity-100"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="At least 8 characters"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Confirm Password</label>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded-md border px-3 py-2"
|
||||
placeholder="Re-enter your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-black px-3 py-2 text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Creating account…" : "Sign Up"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<span className="opacity-70">Already have an account?</span>{" "}
|
||||
<a className="underline" href={`/auth/sign-in?next=${encodeURIComponent(nextPath)}`}>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
app/app/lasers/[id]/LaserDetailsClient.tsx
Normal file
59
app/app/lasers/[id]/LaserDetailsClient.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// app/lasers/[id]/LaserDetailsClient.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
type Group = { title: string; fields: Record<string, string> };
|
||||
type Laser = Record<string, any>;
|
||||
|
||||
const CHOICE_LABELS: Record<string, Record<string, string>> = {
|
||||
op: { pm: "MOPA", pq: "Q-Switch" },
|
||||
cooling: { aa: "Air, Active", ap: "Air, Passive", w: "Water" },
|
||||
};
|
||||
|
||||
function resolveLabel(field: string, value: any) {
|
||||
if (value == null || value === "") return "—";
|
||||
const map = CHOICE_LABELS[field];
|
||||
if (map && typeof value === "string" && map[value]) return map[value];
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "—";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default function LaserDetailsClient({
|
||||
laser,
|
||||
fieldGroups,
|
||||
}: {
|
||||
laser: Laser;
|
||||
fieldGroups: Group[];
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-4">
|
||||
{(laser.make as string) || "—"} {laser.model || ""}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
{fieldGroups.map(({ title, fields }) => (
|
||||
<section key={title} className="bg-card border border-border rounded-xl p-4">
|
||||
<h2 className="text-xl font-semibold mb-2">{title}</h2>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{Object.entries(fields).map(([key, label]) => (
|
||||
<div key={key}>
|
||||
<dt className="font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="text-base break-words">{resolveLabel(key, laser[key])}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Link href="/lasers" className="text-blue-600 underline">
|
||||
← Back to Laser Sources
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
app/app/lasers/[id]/page.tsx
Normal file
82
app/app/lasers/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// app/lasers/[id]/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { dxGET } from "@/lib/directus";
|
||||
import LaserDetailsClient from "./LaserDetailsClient";
|
||||
|
||||
const FIELD_GROUPS = [
|
||||
{
|
||||
title: "General Information",
|
||||
fields: {
|
||||
make: "Make",
|
||||
model: "Model",
|
||||
op: "Pulse Operation Mode",
|
||||
notes: "Notes",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Optical Specifications",
|
||||
fields: {
|
||||
w: "Laser Wattage (W)",
|
||||
mj: "milliJoule Max (mJ)",
|
||||
nm: "Wavelength (nm)",
|
||||
kHz: "Pulse Repetition Rate (kHz)", // NOTE: schema uses kHz (not k_hz)
|
||||
ns: "Pulse Width (ns)",
|
||||
d: "Beam Diameter (mm)",
|
||||
m2: "M² - Quality",
|
||||
instability: "Instability",
|
||||
polarization: "Polarization",
|
||||
band: "Band (nm)",
|
||||
anti: "Anti-Reflection Coating",
|
||||
mw: "Red Dot Wattage (mW)",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Electrical & Timing",
|
||||
fields: {
|
||||
v: "Operating Voltage (V)",
|
||||
temp_op: "Operating Temperature (°C)",
|
||||
temp_store: "Storage Temperature (°C)",
|
||||
l_on: "l_on",
|
||||
l_off: "l_off",
|
||||
mj_c: "mj_c",
|
||||
ns_c: "ns_c",
|
||||
d_c: "d_c",
|
||||
on_c: "on_c",
|
||||
off_c: "off_c",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Integration & Physical",
|
||||
fields: {
|
||||
cable: "Cable Length (m)",
|
||||
cooling: "Cooling Method",
|
||||
weight: "Weight (kg)",
|
||||
dimensions: "Dimensions (cm)",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function Page(
|
||||
{ params }: { params: Promise<{ id: string }> } // Next 15: params is Promise
|
||||
) {
|
||||
const { id: submissionId } = await params;
|
||||
|
||||
const jar = await cookies(); // Next 15: cookies() is async
|
||||
const token = jar.get("ma_at")?.value;
|
||||
if (!token) {
|
||||
redirect(`/auth/sign-in?next=${encodeURIComponent(`/lasers/${submissionId}`)}`);
|
||||
}
|
||||
|
||||
const bearer = `Bearer ${token}`;
|
||||
|
||||
// Pull EVERYTHING the role can see. (No fragile field list.)
|
||||
const res = await dxGET<any>(
|
||||
`/items/laser_source/${encodeURIComponent(submissionId)}?fields=*`,
|
||||
bearer
|
||||
);
|
||||
const laser = res?.data ?? res ?? null;
|
||||
if (!laser) notFound();
|
||||
|
||||
return <LaserDetailsClient laser={laser} fieldGroups={FIELD_GROUPS as any} />;
|
||||
}
|
||||
276
app/app/lasers/page.tsx
Normal file
276
app/app/lasers/page.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
// app/lasers/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type LaserRow = {
|
||||
id: string | number;
|
||||
submission_id?: string | number;
|
||||
make?: string;
|
||||
model?: string;
|
||||
w?: string;
|
||||
mj?: string;
|
||||
nm?: string;
|
||||
kHz?: string;
|
||||
ns?: string;
|
||||
v?: string;
|
||||
op?: { label?: string; name?: string } | string | null;
|
||||
};
|
||||
|
||||
export default function LaserSourcesPage() {
|
||||
const [sources, setSources] = useState<LaserRow[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [wavelengthFilters, setWavelengthFilters] = useState<Record<string, number | null>>({});
|
||||
const [sortKey, setSortKey] = useState<keyof LaserRow | 'op' | 'model'>('model');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/$/, '');
|
||||
|
||||
// canonical href builder (prefers submission_id if present)
|
||||
const detailHref = (row: LaserRow) => `/lasers/${row.submission_id ?? row.id}`;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API}/items/laser_source?limit=-1&fields=*`, { cache: 'no-store' })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSources(data?.data || []))
|
||||
.catch(() => setSources([]));
|
||||
}, [API]);
|
||||
|
||||
const highlightMatch = (text?: string, q?: string) => {
|
||||
const safeText = String(text ?? '');
|
||||
const query = String(q ?? '');
|
||||
if (!query) return safeText;
|
||||
const parts = safeText.split(new RegExp(`(${query})`, 'gi'));
|
||||
return parts.map((part, i) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const opText = (row: LaserRow) => {
|
||||
const v = row.op as any;
|
||||
if (v && typeof v === 'object') return String(v.label ?? v.name ?? '—');
|
||||
return v == null || v === '' ? '—' : String(v);
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return sources.filter((src) => {
|
||||
const matchesQuery = [src.make, src.model]
|
||||
.filter(Boolean)
|
||||
.some((field) => String(field).toLowerCase().includes(q));
|
||||
return matchesQuery;
|
||||
});
|
||||
}, [sources, debouncedQuery]);
|
||||
|
||||
const grouped = useMemo<Record<string, LaserRow[]>>(
|
||||
() =>
|
||||
filtered.reduce((acc, src) => {
|
||||
const key = src.make || 'Unknown Make';
|
||||
(acc[key] = acc[key] || []).push(src);
|
||||
return acc;
|
||||
}, {} as Record<string, LaserRow[]>),
|
||||
[filtered]
|
||||
);
|
||||
|
||||
const toggleFilter = (make: string, value: number) => {
|
||||
setWavelengthFilters((prev) => ({
|
||||
...prev,
|
||||
[make]: prev[make] === value ? null : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleSort = (key: keyof LaserRow | 'op' | 'model') => {
|
||||
setSortKey(key);
|
||||
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
};
|
||||
|
||||
const getSortableValue = (row: LaserRow, key: keyof LaserRow | 'op' | 'model') => {
|
||||
const val = key === 'op' ? opText(row) : (row as any)[key];
|
||||
if (val == null) return '';
|
||||
const k = String(key).toLowerCase();
|
||||
|
||||
if (k === 'w') return parseFloat(String(val).replace(/[^\d.]/g, '')) || 0;
|
||||
if (['mj', 'nm', 'khz', 'ns', 'v'].includes(k)) return parseFloat(String(val)) || 0;
|
||||
|
||||
return String(val).toLowerCase();
|
||||
};
|
||||
|
||||
const sortArrow = (key: keyof LaserRow | 'op' | 'model') =>
|
||||
sortKey === key ? (sortOrder === 'asc' ? ' ▲' : ' ▼') : '';
|
||||
|
||||
const summaryStats = useMemo(() => {
|
||||
const makes = new Set<string>();
|
||||
const nmCounts: Record<string, number> = {};
|
||||
for (const src of sources) {
|
||||
if (src.make) makes.add(src.make);
|
||||
if (src.nm) {
|
||||
const nm = String(src.nm);
|
||||
nmCounts[nm] = (nmCounts[nm] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const mostCommonNm =
|
||||
Object.entries(nmCounts).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))[0]?.[0] || '—';
|
||||
return { total: sources.length, uniqueMakes: makes.size, commonNm: mostCommonNm };
|
||||
}, [sources]);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="grid gap-4 md:grid-cols-3 mb-6">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Database Summary</h2>
|
||||
<p>Total Sources: {summaryStats.total}</p>
|
||||
<p>Unique Makes: {summaryStats.uniqueMakes}</p>
|
||||
<p>Most Common Wavelength: {summaryStats.commonNm}</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Recent Additions</h2>
|
||||
<ul className="text-sm list-disc pl-4">
|
||||
{[...sources]
|
||||
.filter((s) => s.submission_id != null)
|
||||
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
|
||||
.slice(0, 5)
|
||||
.map((src) => (
|
||||
<li key={src.id}>
|
||||
<Link className="text-accent underline" href={detailHref(src)}>
|
||||
{src.make} {src.model}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Feedback</h2>
|
||||
<p className="text-sm mb-2">See something wrong or want to suggest an improvement?</p>
|
||||
<Link href="#" className="btn-primary inline-block">
|
||||
Submit Feedback
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 card bg-card text-card-foreground">
|
||||
<h1 className="text-3xl font-bold mb-2">Laser Source Database</h1>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by make or model..."
|
||||
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse laser source specifications collected from community-submitted and verified sources.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).length === 0 ? (
|
||||
<p className="text-muted">No laser sources found.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(grouped).map(([make, items]) => {
|
||||
const filteredItems =
|
||||
wavelengthFilters[make] != null
|
||||
? items.filter((item) => Number(item.nm) === wavelengthFilters[make])
|
||||
: items;
|
||||
|
||||
const sortedItems = [...filteredItems].sort((a, b) => {
|
||||
const aVal = getSortableValue(a, sortKey);
|
||||
const bVal = getSortableValue(b, sortKey);
|
||||
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<details key={make} className="border border-border rounded-md">
|
||||
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer flex justify-between items-center">
|
||||
<span>
|
||||
{make} <span className="text-sm text-muted">({filteredItems.length})</span>
|
||||
</span>
|
||||
<div className="space-x-2">
|
||||
{[10600, 1064, 455, 355].map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFilter(make, w);
|
||||
}}
|
||||
className={`px-2 py-1 text-xs rounded-md border ${
|
||||
wavelengthFilters[make] === w ? 'bg-accent text-white' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-sm whitespace-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left">Make</th>
|
||||
<th className="px-2 py-2 text-left w-64">
|
||||
<button onClick={() => toggleSort('model')}>Model{sortArrow('model')}</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left">
|
||||
<button onClick={() => toggleSort('w')}>W{sortArrow('w')}</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left">
|
||||
<button onClick={() => toggleSort('mj')}>mJ{sortArrow('mj')}</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left">
|
||||
<button onClick={() => toggleSort('op')}>OP{sortArrow('op')}</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left">
|
||||
<button onClick={() => toggleSort('nm')}>nm{sortArrow('nm')}</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left">
|
||||
<button onClick={() => toggleSort('kHz')}>kHz{sortArrow('kHz')}</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left">
|
||||
<button onClick={() => toggleSort('ns')}>ns{sortArrow('ns')}</button>
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left">
|
||||
<button onClick={() => toggleSort('v')}>V{sortArrow('v')}</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((src) => (
|
||||
<tr key={src.id} className="border-t border-border">
|
||||
<td className="px-2 py-2 truncate max-w-[10rem]">
|
||||
{highlightMatch(src.make || '—', debouncedQuery)}
|
||||
</td>
|
||||
<td className="px-2 py-2 truncate max-w-[16rem]">
|
||||
<Link href={detailHref(src)} className="text-accent underline">
|
||||
{highlightMatch(src.model || '—', debouncedQuery)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{src.w || '—'}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{src.mj || '—'}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{opText(src)}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{src.nm || '—'}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{src.kHz || '—'}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{src.ns || '—'}</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">{src.v || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/app/layout.tsx
Normal file
25
app/app/layout.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// app/layout.tsx
|
||||
import "./styles/globals.css";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
export const metadata = {
|
||||
title: "MakeArmy",
|
||||
description: "Laser Everything community tools",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// Force dark theme (the simplest way to restore your previous look).
|
||||
// If you later want system / toggle support, we can swap this for next-themes.
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function MaterialCoatingDetailsPage() {
|
||||
const { id } = useParams();
|
||||
const [material, setMaterial] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material_coating/${id}?fields=id,name,abbreviation,technical_name,composition,notes,override_reason,coating_status.name,coating_status_override,hazard_tags.hazard_tags_id.hazard_source.source,hazard_tags.hazard_tags_id.hazard_danger.danger,hazard_tags.hazard_tags_id.hazard_severity.severity`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMaterial(data.data || null));
|
||||
}, [id]);
|
||||
|
||||
if (!material) return <div className="p-6">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-4">{material.name}</h1>
|
||||
<div className="space-y-2">
|
||||
<p><strong>Status:</strong> {material.coating_status?.name || '—'}</p>
|
||||
<p><strong>Abbreviation:</strong> {material.abbreviation || '—'}</p>
|
||||
<p><strong>Technical Name:</strong> {material.technical_name || '—'}</p>
|
||||
<p><strong>Composition:</strong> {material.composition || '—'}</p>
|
||||
<p><strong>Notes:</strong> {material.notes || '—'}</p>
|
||||
<p><strong>Override Reason:</strong> {material.override_reason || '—'}</p>
|
||||
|
||||
<div>
|
||||
<strong>Hazard Tags</strong>
|
||||
<ul className="list-disc pl-6">
|
||||
{Array.isArray(material.hazard_tags) && material.hazard_tags.length > 0 ? (
|
||||
material.hazard_tags.map((tag, index) => (
|
||||
<li key={index}>
|
||||
{tag.hazard_tags_id?.hazard_source?.source || '—'} |{' '}
|
||||
{tag.hazard_tags_id?.hazard_danger?.danger || '—'} |{' '}
|
||||
{tag.hazard_tags_id?.hazard_severity?.severity || '—'}
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>None</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Link href="/materials-coatings" className="text-blue-600 underline">← Back to Coatings</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
2
app/app/materials/materials-coatings/[id]/page.tsx
Normal file
2
app/app/materials/materials-coatings/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/materials/materials-coatings/[id]/page.tsx
|
||||
export { default } from "./materials-coatings";
|
||||
146
app/app/materials/materials-coatings/page.tsx
Normal file
146
app/app/materials/materials-coatings/page.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
function highlightMatch(text?: string, query?: string) {
|
||||
const safeText = String(text ?? '');
|
||||
const q = String(query ?? '');
|
||||
if (!q) return safeText;
|
||||
const parts = safeText.split(new RegExp(`(${q})`, 'gi'));
|
||||
return parts.map((part, i) =>
|
||||
part.toLowerCase() === q.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoatingsPage() {
|
||||
const [coatings, setCoatings] = useState<any[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
|
||||
// canonical detail href (no modal yet)
|
||||
const detailHref = (id: string | number) => `/materials/materials-coatings/${id}`;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material_coating?fields=id,name,abbreviation,technical_name,composition,coating_status.name&limit=-1`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setCoatings(data.data || []));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return coatings.filter((coat) =>
|
||||
[
|
||||
coat.name,
|
||||
coat.technical_name,
|
||||
coat.abbreviation,
|
||||
coat.composition,
|
||||
coat.coating_status?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field: string) => String(field).toLowerCase().includes(q))
|
||||
);
|
||||
}, [coatings, debouncedQuery]);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-6 flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
|
||||
<h1 className="text-3xl font-bold mb-2">Laser Material Coatings</h1>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search coatings..."
|
||||
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
|
||||
<p className="mb-6">
|
||||
The following coatings are provided for educational purposes only and are not intended to be used as your
|
||||
sole or primary source of information when assessing the safety of any particular coating. It is your
|
||||
responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is,
|
||||
free of charge under the assumption you are exercising all other relevant safety precautions.
|
||||
</p>
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<Link href="/" className="btn-primary">
|
||||
← Back to Main Menu
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 card bg-[#422c17] text-white">
|
||||
<h2 className="font-bold text-base mb-2">⚠ Safety Level Definitions</h2>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<strong>Safe</strong> – Materials marked as safe are widely considered to be generally safe by the laser
|
||||
community at large. This does not mean normal safety protocols should not be observed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Level I – Caution</strong> | These materials are typically safe when normal safety protocol
|
||||
observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper
|
||||
exhaust and filtration.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Level II – Dangerous</strong> | These materials can be harmful even if normal safety protocol
|
||||
observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and
|
||||
mindfulness.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Level III – Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or
|
||||
death. Materials marked Critical Hazard should not be processed by lasers in any environment for any
|
||||
reason.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted">No coatings found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-fixed min-w-full border border-border text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left w-48">Name</th>
|
||||
<th className="px-4 py-2 text-left w-32 whitespace-nowrap">Status</th>
|
||||
<th className="px-4 py-2 text-left w-32">Abbreviation</th>
|
||||
<th className="px-4 py-2 text-left w-64">Technical Name</th>
|
||||
<th className="px-4 py-2 text-left w-64">Composition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((coat) => (
|
||||
<tr key={coat.id} className="border-t border-border align-top">
|
||||
<td className="px-4 py-2 truncate max-w-[12rem]">
|
||||
<Link href={detailHref(coat.id)} className="text-accent underline">
|
||||
{highlightMatch(coat.name, debouncedQuery)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{highlightMatch(coat.coating_status?.name || '—', debouncedQuery)}
|
||||
</td>
|
||||
<td className="px-4 py-2 truncate max-w-[8rem]">
|
||||
{highlightMatch(coat.abbreviation || '—', debouncedQuery)}
|
||||
</td>
|
||||
<td className="px-4 py-2 truncate max-w-[16rem]">
|
||||
{highlightMatch(coat.technical_name || '—', debouncedQuery)}
|
||||
</td>
|
||||
<td className="px-4 py-2 truncate max-w-[16rem]">
|
||||
{highlightMatch(coat.composition || '—', debouncedQuery)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
app/app/materials/materials/[id]/materials.tsx
Normal file
60
app/app/materials/materials/[id]/materials.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
export default function MaterialDetailsPage() {
|
||||
const { id } = useParams();
|
||||
const [material, setMaterial] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material/${id}?fields=id,name,abbreviation,common_names,technical_name,composition,material_cat.name,material_status.name,notes,override_reason,hazard_tags.hazard_tags_id.hazard_source.source,hazard_tags.hazard_tags_id.hazard_danger.danger,hazard_tags.hazard_tags_id.hazard_severity.severity`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMaterial(data.data || null));
|
||||
}, [id]);
|
||||
|
||||
if (!material) return <div className="p-6">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-4">{material.name}</h1>
|
||||
<div className="space-y-2">
|
||||
<p><strong>Category:</strong> {material.material_cat?.name || '—'}</p>
|
||||
<p><strong>Status:</strong> {material.material_status?.name || '—'}</p>
|
||||
<p><strong>Abbreviation:</strong> {material.abbreviation || '—'}</p>
|
||||
<p><strong>Common Names:</strong> {material.common_names || '—'}</p>
|
||||
<p><strong>Technical Name:</strong> {material.technical_name || '—'}</p>
|
||||
<p><strong>Composition:</strong> {material.composition || '—'}</p>
|
||||
<p><strong>Notes:</strong> {material.notes || '—'}</p>
|
||||
<p><strong>Override Reason:</strong> {material.override_reason || '—'}</p>
|
||||
|
||||
<div>
|
||||
<strong>Hazard Tags</strong>
|
||||
<ul className="list-disc pl-6">
|
||||
{Array.isArray(material.hazard_tags) && material.hazard_tags.length > 0 ? (
|
||||
material.hazard_tags.map((tag, index) => (
|
||||
<li key={index}>
|
||||
{tag.hazard_tags_id?.hazard_source?.source || '—'} |{' '}
|
||||
{tag.hazard_tags_id?.hazard_danger?.danger || '—'} |{' '}
|
||||
{tag.hazard_tags_id?.hazard_severity?.severity || '—'}
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>None</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Link href="/materials" className="text-blue-600 underline">← Back to Materials</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
2
app/app/materials/materials/[id]/page.tsx
Normal file
2
app/app/materials/materials/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/materials/materials/[id]/page.tsx
|
||||
export { default } from "./materials";
|
||||
161
app/app/materials/materials/page.tsx
Normal file
161
app/app/materials/materials/page.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
function highlightMatch(text: string, query: string) {
|
||||
if (!query) return text;
|
||||
const parts = String(text).split(new RegExp(`(${query})`, 'gi'));
|
||||
return parts.map((part, i) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MaterialsPage() {
|
||||
const [materials, setMaterials] = useState<any[]>([]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
|
||||
// canonical detail href builder (no modal yet)
|
||||
const detailHref = (id: string | number) => `/materials/materials/${id}`;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/material?fields=id,name,abbreviation,common_names,technical_name,material_cat.name,material_status.name&limit=-1`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMaterials(data.data || []));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return materials.filter((mat) =>
|
||||
[
|
||||
mat.name,
|
||||
mat.technical_name,
|
||||
mat.common_names,
|
||||
mat.abbreviation,
|
||||
mat.material_status?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((field: string) => String(field).toLowerCase().includes(q))
|
||||
);
|
||||
}, [materials, debouncedQuery]);
|
||||
|
||||
const grouped = useMemo<Record<string, typeof filtered>>(() => {
|
||||
return filtered.reduce((acc, mat) => {
|
||||
const key = mat.material_cat?.name || 'Uncategorized';
|
||||
(acc[key] = acc[key] || []).push(mat);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof filtered>);
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-6 flex flex-col lg:flex-row gap-4">
|
||||
<div className="flex-1 card bg-card text-card-foreground relative pb-16">
|
||||
<h1 className="text-3xl font-bold mb-2">Laser Material Reference</h1>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search materials..."
|
||||
className="w-full max-w-md mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<h2 className="font-semibold mb-1">📌 Disclaimer</h2>
|
||||
<p className="mb-6">
|
||||
The following materials are provided for educational purposes only and are not intended to be used as your
|
||||
sole or primary source of information when assessing the safety of any particular material. It is your
|
||||
responsibility alone to ensure your safety when operating your equipment. This resource is provided as-is,
|
||||
free of charge under the assumption you are exercising all other relevant safety precautions.
|
||||
</p>
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<Link href="/" className="btn-primary">
|
||||
← Back to Main Menu
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 card bg-[#422c17] text-white">
|
||||
<h2 className="font-bold text-base mb-2">⚠ Safety Level Definitions</h2>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<strong>Safe</strong> – Materials marked as safe are widely considered to be generally safe by the laser
|
||||
community at large. This does not mean normal safety protocols should not be observed.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Level I – Caution</strong> | These materials are typically safe when normal safety protocol
|
||||
observed. This includes skin, eye and respiratory protection, regulated marking parameters, and proper
|
||||
exhaust and filtration.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Level II – Dangerous</strong> | These materials can be harmful even if normal safety protocol
|
||||
observed. Strict adherence to safety protocols required at all times. Exercise extreme caution and
|
||||
mindfulness.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Level III – Critical Hazard</strong> | These materials pose an imminent threat of bodily harm or
|
||||
death. Materials marked Critical Hazard should not be processed by lasers in any environment for any
|
||||
reason.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).length === 0 ? (
|
||||
<p className="text-muted">No materials found.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<details key={category} className="border border-border rounded-md">
|
||||
<summary className="bg-card px-4 py-2 font-semibold cursor-pointer">
|
||||
{category} <span className="text-sm text-muted">({items.length})</span>
|
||||
</summary>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-48">Name</th>
|
||||
<th className="w-32 whitespace-nowrap">Status</th>
|
||||
<th className="w-32">Abbreviation</th>
|
||||
<th className="w-64">Common Names</th>
|
||||
<th className="w-64">Technical Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((material) => (
|
||||
<tr key={material.id} className="border-t border-border align-top">
|
||||
<td className="truncate max-w-[12rem]">
|
||||
<Link href={detailHref(material.id)} className="text-accent underline">
|
||||
{highlightMatch(material.name, debouncedQuery)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
{highlightMatch(material.material_status?.name || '—', debouncedQuery)}
|
||||
</td>
|
||||
<td className="truncate max-w-[8rem]">
|
||||
{highlightMatch(material.abbreviation || '—', debouncedQuery)}
|
||||
</td>
|
||||
<td className="truncate max-w-[16rem]">
|
||||
{highlightMatch(material.common_names || '—', debouncedQuery)}
|
||||
</td>
|
||||
<td className="truncate max-w-[16rem]">
|
||||
{highlightMatch(material.technical_name || '—', debouncedQuery)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
app/app/page.tsx
Normal file
49
app/app/page.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// app/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import SignIn from "@/app/auth/sign-in/sign-in";
|
||||
import SignUp from "@/app/auth/sign-up/sign-up";
|
||||
import { isJwtValid } from "@/lib/jwt";
|
||||
|
||||
type SearchParams = { [key: string]: string | string[] | undefined };
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: SearchParams;
|
||||
}) {
|
||||
// If already signed in with a VALID token, go straight to the app
|
||||
const ck = await cookies();
|
||||
const at = ck.get("ma_at")?.value;
|
||||
if (isJwtValid(at)) redirect("/portal");
|
||||
|
||||
const reauth = searchParams?.reauth === "1";
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl px-4 py-12">
|
||||
{reauth && (
|
||||
<p className="mb-6 rounded-md border bg-yellow-50 p-3 text-sm text-yellow-900">
|
||||
Your session expired. Please sign in again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">MakeArmy</h1>
|
||||
<p className="mt-2 text-base text-muted-foreground">
|
||||
Free to use. Manage laser rigs, settings, and projects—all in one
|
||||
place.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-2">
|
||||
<SignUp nextPath="/portal" />
|
||||
<SignIn nextPath="/portal" reauth={reauth} />
|
||||
</section>
|
||||
|
||||
<section className="mt-8 text-center text-xs text-muted-foreground">
|
||||
<p>This is the beta build v0.1.1 - this site is an active BETA. Some features may not be available or work as intended. If you're experiencing issues please report them here: https://forge.makearmy.io/makearmy/makearmy-app/issues </p>
|
||||
<p>PRIVACY: We only use cookies strictly necessary to operate the site (e.g., your sign-in session). We do not store user data or telemetry other than what you provide and never share or sell data to third parties. Ever.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
128
app/app/portal/account/AccountPanel.tsx
Normal file
128
app/app/portal/account/AccountPanel.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// app/portal/account/AccountPanel.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
|
||||
// Use relative paths to avoid alias resolution issues in this route
|
||||
import ProfileEditor from "../../../components/account/ProfileEditor";
|
||||
import PasswordChange from "../../../components/account/PasswordChange";
|
||||
import AvatarUploader from "../../../components/account/AvatarUploader";
|
||||
import SupporterBadges from "../../../components/account/SupporterBadges";
|
||||
import ConnectKofi from "../../../components/account/ConnectKofi";
|
||||
import LinkStatus from "../../../components/account/LinkStatus";
|
||||
|
||||
type Avatar = { id: string; filename_download?: string } | null;
|
||||
type Me = {
|
||||
id: string;
|
||||
username: string;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
location?: string | null;
|
||||
avatar?: Avatar;
|
||||
};
|
||||
|
||||
export default function AccountPanel() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Precompute API base once on the client
|
||||
const API_BASE = useMemo(
|
||||
() => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""),
|
||||
[]
|
||||
);
|
||||
|
||||
const refetchMe = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/account", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||
const j = await r.json();
|
||||
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
|
||||
if (!user) throw new Error("Malformed response");
|
||||
setMe(user);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to load account");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
setErr(null);
|
||||
await refetchMe();
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [refetchMe]);
|
||||
|
||||
if (loading) return <div className="rounded-md border p-6 text-sm opacity-70">Loading…</div>;
|
||||
if (err) return <div className="rounded-md border p-6 text-red-600">Error: {err}</div>;
|
||||
if (!me) return null;
|
||||
|
||||
const avatarUrl = me.avatar?.id ? `${API_BASE}/assets/${me.avatar.id}` : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Account</h2>
|
||||
{/* Shows success/failure after Ko-fi verify redirect */}
|
||||
<LinkStatus />
|
||||
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
|
||||
{avatarUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={avatarUrl} alt="Avatar" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs opacity-60">No Avatar</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Usernames can’t be changed.</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="opacity-60">Username</div>
|
||||
<div className="font-medium">{me.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="opacity-60">First Name</div>
|
||||
<div>{me.first_name || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="opacity-60">Last Name</div>
|
||||
<div>{me.last_name || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="opacity-60">Email</div>
|
||||
<div>{me.email || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="opacity-60">Location</div>
|
||||
<div>{me.location || "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges (derive-on-read, no cron) */}
|
||||
<SupporterBadges email={me.email ?? undefined} userId={me.id} />
|
||||
</div>
|
||||
|
||||
{/* Link Ko-fi */}
|
||||
<ConnectKofi email={me.email} userId={me.id} />
|
||||
|
||||
{/* Editable sections */}
|
||||
<AvatarUploader avatarId={me.avatar?.id || null} onUpdated={refetchMe} />
|
||||
<ProfileEditor me={me} onUpdated={refetchMe} />
|
||||
<PasswordChange />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/app/portal/account/page.tsx
Normal file
16
app/app/portal/account/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// app/portal/account/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AccountPage() {
|
||||
const at = (await cookies()).get("ma_at")?.value;
|
||||
if (!at) {
|
||||
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal/account")}`);
|
||||
}
|
||||
// No reauth gating here; the panel fetches and renders profile,
|
||||
// and only asks for reauth when the user submits sensitive changes.
|
||||
return <AccountPanel />;
|
||||
}
|
||||
16
app/app/portal/buying-guide/page.tsx
Normal file
16
app/app/portal/buying-guide/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const BuyingGuideSwitcher = dynamic(
|
||||
() => import("@/components/portal/BuyingGuideSwitcher"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export default function PortalBuyingGuidePage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<BuyingGuideSwitcher />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
app/app/portal/laser-settings/Client.tsx
Normal file
58
app/app/portal/laser-settings/Client.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// app/portal/laser-settings/Client.tsx
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
const SettingsSwitcher = dynamic(
|
||||
() => import("@/components/portal/SettingsSwitcher"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
function DetailsFrame({ src }: { src: string }) {
|
||||
return (
|
||||
<iframe
|
||||
key={src}
|
||||
src={src}
|
||||
className="w-full h-[70vh] rounded-md border"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LaserSettingsClient() {
|
||||
const search = useSearchParams();
|
||||
const t = (search.get("t") || "fiber").toLowerCase();
|
||||
const id = search.get("id");
|
||||
const view = (search.get("view") || "list").toLowerCase();
|
||||
|
||||
// Only use legacy detail pages for tabs we haven't migrated yet.
|
||||
// CO₂ Galvo renders its detail INSIDE the panel, so never iFrame it here.
|
||||
const detailSrc =
|
||||
id && view === "detail" && (t === "fiber" || t === "uv" || t === "co2-gantry")
|
||||
? t === "fiber"
|
||||
? `/settings/fiber/${id}`
|
||||
: t === "uv"
|
||||
? `/settings/uv/${id}`
|
||||
: `/settings/co2-gantry/${id}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Laser Settings</h2>
|
||||
<SettingsSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Only show legacy iframe for non-CO₂ Galvo tabs, and only when explicitly in detail view */}
|
||||
{detailSrc && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 text-sm opacity-70">
|
||||
Viewing detail #{id} ({t})
|
||||
</div>
|
||||
<DetailsFrame src={detailSrc} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/laser-settings/page.tsx
Normal file
8
app/app/portal/laser-settings/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// app/portal/laser-settings/page.tsx
|
||||
import LaserSettingsClient from "./Client";
|
||||
|
||||
export const metadata = { title: "MakerDash • Laser Settings" };
|
||||
|
||||
export default function Page() {
|
||||
return <LaserSettingsClient />;
|
||||
}
|
||||
38
app/app/portal/laser-sources/Client.tsx
Normal file
38
app/app/portal/laser-sources/Client.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
const LasersList = dynamic(() => import("@/app/lasers/page"), { ssr: false });
|
||||
|
||||
function DetailsFrame({ src }: { src: string }) {
|
||||
return (
|
||||
<iframe
|
||||
key={src}
|
||||
src={src}
|
||||
className="w-full h-[70vh] rounded-md border"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LaserSourcesClient() {
|
||||
const search = useSearchParams();
|
||||
const id = search.get("id");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Laser Sources</h2>
|
||||
<LasersList />
|
||||
</div>
|
||||
|
||||
{id && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 text-sm opacity-70">Viewing source #{id}</div>
|
||||
<DetailsFrame src={`/lasers/${id}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/laser-sources/page.tsx
Normal file
8
app/app/portal/laser-sources/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// app/portal/laser-sources/page.tsx
|
||||
import LaserSourcesClient from "./Client";
|
||||
|
||||
export const metadata = { title: "MakerDash • Laser Sources" };
|
||||
|
||||
export default function Page() {
|
||||
return <LaserSourcesClient />;
|
||||
}
|
||||
30
app/app/portal/layout.tsx
Normal file
30
app/app/portal/layout.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// app/portal/layout.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import PortalTabs from "@/components/PortalTabs";
|
||||
import SignOutButton from "@/components/SignOutButton";
|
||||
|
||||
export const metadata = { title: "MakerDash" };
|
||||
|
||||
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
// Auth gate: require user access token cookie
|
||||
const store = await cookies();
|
||||
const at = store.get("ma_at")?.value;
|
||||
if (!at) {
|
||||
// preserve deep-link by defaulting to /portal
|
||||
redirect(`/auth/sign-in?next=${encodeURIComponent("/portal")}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-6">
|
||||
<header className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Welcome to MakerDash</h1>
|
||||
<SignOutButton className="text-sm opacity-75 hover:opacity-100" />
|
||||
</header>
|
||||
|
||||
<PortalTabs />
|
||||
|
||||
<section className="mt-6">{children}</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
57
app/app/portal/materials/Client.tsx
Normal file
57
app/app/portal/materials/Client.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
const MaterialsList = dynamic(
|
||||
() => import("@/app/materials/materials/page"),
|
||||
{ ssr: false }
|
||||
);
|
||||
const CoatingsList = dynamic(
|
||||
() => import("@/app/materials/materials-coatings/page"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
function DetailsFrame({ src }: { src: string }) {
|
||||
return (
|
||||
<iframe
|
||||
key={src}
|
||||
src={src}
|
||||
className="w-full h-[70vh] rounded-md border"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MaterialsClient() {
|
||||
const search = useSearchParams();
|
||||
const t = (search.get("t") || "materials").toLowerCase();
|
||||
const id = search.get("id");
|
||||
|
||||
const detailSrc =
|
||||
!id
|
||||
? null
|
||||
: t === "materials"
|
||||
? `/materials/materials/${id}`
|
||||
: t === "materials-coatings"
|
||||
? `/materials/materials-coatings/${id}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Materials</h2>
|
||||
{t === "materials" ? <MaterialsList /> : <CoatingsList />}
|
||||
</div>
|
||||
|
||||
{detailSrc && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 text-sm opacity-70">
|
||||
Viewing {t.replace("-", " ")} #{id}
|
||||
</div>
|
||||
<DetailsFrame src={detailSrc} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/materials/page.tsx
Normal file
8
app/app/portal/materials/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// app/portal/materials/page.tsx
|
||||
import MaterialsClient from "./Client";
|
||||
|
||||
export const metadata = { title: "MakerDash • Materials" };
|
||||
|
||||
export default function Page() {
|
||||
return <MaterialsClient />;
|
||||
}
|
||||
248
app/app/portal/my-settings/page.tsx
Normal file
248
app/app/portal/my-settings/page.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// app/portal/my-settings/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type Coll = "settings_co2gal" | "settings_co2gan" | "settings_fiber" | "settings_uv";
|
||||
|
||||
type Row = {
|
||||
submission_id: string | number;
|
||||
setting_title?: string | null;
|
||||
last_modified_date?: string | null;
|
||||
// NOTE: we intentionally do NOT request id or status due to permissions
|
||||
};
|
||||
|
||||
const COLLECTIONS: Coll[] = ["settings_co2gal", "settings_co2gan", "settings_fiber", "settings_uv"];
|
||||
const LABEL: Record<Coll, string> = {
|
||||
settings_co2gal: "CO₂ Galvo",
|
||||
settings_co2gan: "CO₂ Gantry",
|
||||
settings_fiber: "Fiber",
|
||||
settings_uv: "UV",
|
||||
};
|
||||
|
||||
function detailHref(coll: Coll, submissionId: string | number | null | undefined) {
|
||||
const sid = submissionId ?? "";
|
||||
switch (coll) {
|
||||
case "settings_co2gal": return `/settings/co2-galvo/${sid}?edit=1`;
|
||||
case "settings_co2gan": return `/settings/co2-gantry/${sid}?edit=1`;
|
||||
case "settings_fiber": return `/settings/fiber/${sid}?edit=1`;
|
||||
case "settings_uv": return `/settings/uv/${sid}?edit=1`;
|
||||
}
|
||||
}
|
||||
|
||||
export default function MySettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [meId, setMeId] = useState<string | null>(null);
|
||||
const [meUsername, setMeUsername] = useState<string | null>(null); // used only for display
|
||||
const [q, setQ] = useState("");
|
||||
const [byColl, setByColl] = useState<Record<Coll, Row[]>>({
|
||||
settings_co2gal: [],
|
||||
settings_co2gan: [],
|
||||
settings_fiber: [],
|
||||
settings_uv: [],
|
||||
});
|
||||
const [errs, setErrs] = useState<Record<Coll | "me", string | null>>({
|
||||
me: null,
|
||||
settings_co2gal: null,
|
||||
settings_co2gan: null,
|
||||
settings_fiber: null,
|
||||
settings_uv: null,
|
||||
});
|
||||
|
||||
async function readJson(res: Response) {
|
||||
const text = await res.text();
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`Unexpected response (HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Load current user id + username
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/dx/users/me?fields=id,username`, { credentials: "include", cache: "no-store" });
|
||||
if (!r.ok) {
|
||||
const j = await readJson(r).catch(() => null);
|
||||
throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
|
||||
}
|
||||
const j = await readJson(r);
|
||||
const id = j?.data?.id ?? j?.id ?? null;
|
||||
const un = j?.data?.username ?? j?.username ?? null;
|
||||
if (!dead) {
|
||||
setMeId(id ? String(id) : null);
|
||||
setMeUsername(un ? String(un) : null);
|
||||
setErrs((e) => ({ ...e, me: null }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!dead) setErrs((er) => ({ ...er, me: e?.message || String(e) }));
|
||||
}
|
||||
})();
|
||||
return () => { dead = true; };
|
||||
}, []);
|
||||
|
||||
// 2) Load my items per collection (STRICT owner-only)
|
||||
useEffect(() => {
|
||||
if (!meId) return; // we require the user id to filter by owner
|
||||
let dead = false;
|
||||
setLoading(true);
|
||||
setErrs((e) => ({ ...e, settings_co2gal: null, settings_co2gan: null, settings_fiber: null, settings_uv: null }));
|
||||
|
||||
(async () => {
|
||||
const acc: Record<Coll, Row[]> = { settings_co2gal: [], settings_co2gan: [], settings_fiber: [], settings_uv: [] };
|
||||
|
||||
for (const coll of COLLECTIONS) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("limit", "-1");
|
||||
qs.set("sort", "-last_modified_date");
|
||||
qs.set("fields", "submission_id,setting_title,last_modified_date"); // minimal, safe fields
|
||||
|
||||
// STRICT owner filter. We OR the two shapes:
|
||||
// - owner stored as primitive id
|
||||
// - owner stored as relation object { id: ... }
|
||||
qs.set(`filter[_or][0][owner][_eq]`, meId);
|
||||
qs.set(`filter[_or][1][owner][id][_eq]`, meId);
|
||||
|
||||
const url = `/api/dx/items/${coll}?${qs.toString()}`;
|
||||
|
||||
try {
|
||||
const r = await fetch(url, { credentials: "include", cache: "no-store" });
|
||||
if (!r.ok) {
|
||||
const j = await readJson(r).catch(() => null);
|
||||
throw new Error(j?.errors?.[0]?.message || `HTTP ${r.status}`);
|
||||
}
|
||||
const j = await readJson(r);
|
||||
const rows: Row[] = Array.isArray(j?.data) ? j.data : [];
|
||||
acc[coll] = rows;
|
||||
} catch (e: any) {
|
||||
acc[coll] = [];
|
||||
setErrs((er) => ({ ...er, [coll]: e?.message || String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!dead) {
|
||||
setByColl(acc);
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { dead = true; };
|
||||
}, [meId]);
|
||||
|
||||
// 3) Filter client-side
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return byColl;
|
||||
const out: Record<Coll, Row[]> = { settings_co2gal: [], settings_co2gan: [], settings_fiber: [], settings_uv: [] };
|
||||
for (const coll of COLLECTIONS) {
|
||||
out[coll] = (byColl[coll] || []).filter(r =>
|
||||
[r.setting_title, r.last_modified_date]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(needle))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}, [byColl, q]);
|
||||
|
||||
async function onDelete(coll: Coll, row: Row) {
|
||||
if (!row.submission_id) return alert("Missing submission id.");
|
||||
if (!confirm(`Delete "${row.setting_title || "Untitled"}" from ${LABEL[coll]}?`)) return;
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/my-settings/delete`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ collection: coll, submission_id: row.submission_id }),
|
||||
});
|
||||
const j = await readJson(r).catch(() => null);
|
||||
if (!r.ok || !j?.ok) {
|
||||
throw new Error(j?.error || `HTTP ${r.status}`);
|
||||
}
|
||||
setByColl(prev => ({
|
||||
...prev,
|
||||
[coll]: prev[coll].filter(x => String(x.submission_id) !== String(row.submission_id))
|
||||
}));
|
||||
} catch (e: any) {
|
||||
alert(`Delete failed: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
const total = COLLECTIONS.reduce((n, c) => n + (filtered[c]?.length || 0), 0);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
||||
<h1 className="text-2xl font-semibold mb-4">My Settings</h1>
|
||||
|
||||
{!!errs.me && (
|
||||
<div className="mb-4 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
Couldn’t load your profile: {errs.me}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<input
|
||||
className="border rounded px-3 py-2 w-full max-w-md"
|
||||
placeholder="Search my settings…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.currentTarget.value)}
|
||||
/>
|
||||
<span className="text-sm opacity-70">{total} total</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p>Loading…</p>
|
||||
) : (
|
||||
COLLECTIONS.map((coll) => {
|
||||
const rows = filtered[coll] || [];
|
||||
return (
|
||||
<section key={coll} className="mb-8">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{LABEL[coll]} <span className="text-xs opacity-70">({rows.length})</span>
|
||||
</h2>
|
||||
{!!errs[coll] && (
|
||||
<span className="text-xs text-red-600">Error: {errs[coll]}</span>
|
||||
)}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<p className="opacity-70">No items.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
<th className="px-2 py-2 text-left">Title</th>
|
||||
<th className="px-2 py-2 text-left">Updated</th>
|
||||
<th className="px-2 py-2 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={`${coll}:${r.submission_id}`} className="border-b hover:bg-muted/30">
|
||||
<td className="px-2 py-2">{r.setting_title || "Untitled"}</td>
|
||||
<td className="px-2 py-2">
|
||||
{r.last_modified_date ? new Date(r.last_modified_date).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={detailHref(coll, r.submission_id)} className="underline">Edit</Link>
|
||||
<button className="text-red-600 underline" onClick={() => onDelete(coll, r)}>Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
275
app/app/portal/page.tsx
Normal file
275
app/app/portal/page.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
CircleX,
|
||||
HeartHandshake,
|
||||
HandCoins,
|
||||
Coffee,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Video,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
// shadcn/ui
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* App Dashboard: Support CTA (polished)
|
||||
* - Stable custom buttons (no hover flicker)
|
||||
* - Perks on their own lines
|
||||
* - Copy + outline adjustments per request
|
||||
*/
|
||||
|
||||
const perks = [
|
||||
{ icon: <ShieldCheck className="h-5 w-5" aria-hidden="true" />, text: "No ads. No sponsors. No influence." },
|
||||
{ icon: <Users className="h-5 w-5" aria-hidden="true" />, text: "Community-funded, community-first priorities." },
|
||||
{ icon: <Video className="h-5 w-5" aria-hidden="true" />, text: "Videos, tools, and guides stay open for all." },
|
||||
];
|
||||
|
||||
const supportTiers = [
|
||||
{
|
||||
name: "Laser Master Academy",
|
||||
href: "https://masters.lasereverything.net",
|
||||
icon: <Sparkles className="h-6 w-6" aria-hidden="true" />,
|
||||
blurb:
|
||||
"Our flagship learning community on MightyNetworks: structured courses, AMAs, and deeper mentorship.",
|
||||
perks: ["Parameter Packs", "Support Forums", "Bonus Content Archives"],
|
||||
cta: "Join the Academy",
|
||||
},
|
||||
{
|
||||
name: "Patreon",
|
||||
href: "https://www.patreon.com/c/LaserEverything",
|
||||
icon: <HeartHandshake className="h-6 w-6" aria-hidden="true" />,
|
||||
blurb:
|
||||
"Flexible monthly support to underwrite videos, research, and open tools without monetization strings.",
|
||||
perks: ["Directly Support Us", "Behind-the-Scenes Notes", "Community Polls"],
|
||||
cta: "Back on Patreon",
|
||||
},
|
||||
{
|
||||
name: "Ko-Fi",
|
||||
href: "https://ko-fi.com/lasereverything",
|
||||
icon: <Coffee className="h-6 w-6" aria-hidden="true" />,
|
||||
blurb:
|
||||
"One-time tips that go straight to hosting, development, and production costs — no paywall, just fuel.",
|
||||
perks: ["Say thanks once", "Help cover a bill", "Keep it free for others"],
|
||||
cta: "Tip on Ko-Fi",
|
||||
},
|
||||
];
|
||||
|
||||
const adVsCommunity = {
|
||||
ads: [
|
||||
"Algorithm-shaped content",
|
||||
"Sponsor talking points",
|
||||
"Influencer bias risk",
|
||||
"Tracking and interruptions",
|
||||
],
|
||||
community: [
|
||||
"Curriculum set by makers",
|
||||
"Honest reviews & hard truths",
|
||||
"Open tools without strings",
|
||||
"Privacy-respecting experience",
|
||||
],
|
||||
};
|
||||
|
||||
// Reusable button styles (anchors styled as buttons to avoid shadcn hover conflicts)
|
||||
const btn =
|
||||
"inline-flex items-center justify-center rounded-2xl px-5 py-3 text-sm font-medium text-white shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2";
|
||||
const btnTeal = `${btn} bg-teal-600 hover:bg-teal-700 focus-visible:ring-teal-500`;
|
||||
const btnRose = `${btn} bg-rose-600 hover:bg-rose-700 focus-visible:ring-rose-500`;
|
||||
const btnSky = `${btn} bg-sky-600 hover:bg-sky-700 focus-visible:ring-sky-500`;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="min-h-[calc(100dvh-4rem)] w-full">
|
||||
{/* Hero */}
|
||||
<section className="mx-auto w-full max-w-6xl px-6 py-14 sm:py-16">
|
||||
<div className="text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
Community-Funded • Ad-Free • Open Resources
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-3xl font-extrabold tracking-tight sm:text-4xl">
|
||||
Keep Laser Everything & MakeArmy FREE for Everyone
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-3 max-w-3xl text-balance text-sm text-muted-foreground sm:text-base">
|
||||
The videos, tools, and docs you use today exist because previous supporters paid it forward.
|
||||
We want to stay independent — no ads, no sponsors, no strings — and that only works if we fund it together.
|
||||
</p>
|
||||
|
||||
{/* CTA row (custom anchors = stable hover) */}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="https://masters.lasereverything.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Join Laser Master Academy"
|
||||
className={btnTeal}
|
||||
>
|
||||
Join the LMA <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://www.patreon.com/c/LaserEverything"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Back Laser Everything on Patreon"
|
||||
className={btnRose}
|
||||
>
|
||||
Back on Patreon
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://ko-fi.com/lasereverything"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Contribute on Ko-Fi"
|
||||
className={btnSky}
|
||||
>
|
||||
Tip on Ko-Fi
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Perks: each on its own line, centered */}
|
||||
<ul className="mx-auto mt-5 max-w-xl space-y-2 text-muted-foreground">
|
||||
{perks.map((p, i) => (
|
||||
<li key={i} className="flex items-center justify-center gap-2 text-sm">
|
||||
{p.icon}
|
||||
<span>{p.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Support Options */}
|
||||
<section className="mx-auto -mt-2 max-w-6xl px-6 pb-12">
|
||||
<div className="rounded-3xl border bg-card p-6 shadow-sm sm:p-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How you can help—pick what fits</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Whether you join the Academy, pledge monthly, or tip once, every contribution sustains the whole community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{supportTiers.map((tier) => (
|
||||
<Card key={tier.name} className="group rounded-2xl transition hover:shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex items-center gap-2 text-teal-600 dark:text-teal-400">
|
||||
{tier.icon}
|
||||
<CardTitle className="text-lg">{tier.name}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{tier.blurb}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{tier.perks.map((perk) => (
|
||||
<li key={perk} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {perk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={tier.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-colors hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
|
||||
>
|
||||
{tier.cta}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Philosophy: Ads vs Community */}
|
||||
<div className="mt-10">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h3 className="text-xl font-semibold">Why not just run ads & sponsors?</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Because it quietly changes what we make and who we serve.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">We’d rather be accountable to you.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Dim outline for Advertising box */}
|
||||
<Card className="rounded-2xl border border-foreground/10 dark:border-foreground/15">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-rose-600 dark:text-rose-400">
|
||||
<CircleX className="h-5 w-5" aria-hidden="true" />
|
||||
<CardTitle className="text-base">Advertising / Sponsorship Model</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{adVsCommunity.ads.map((t) => (
|
||||
<li key={t} className="flex items-center gap-2">
|
||||
<CircleX className="h-4 w-4" aria-hidden="true" /> {t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Brighter outline for Community box */}
|
||||
<Card className="rounded-2xl border border-teal-500/50 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-teal-700 dark:text-teal-300">
|
||||
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
|
||||
<CardTitle className="text-base">Community-Funded Model</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{adVsCommunity.community.map((t) => (
|
||||
<li key={t} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-10 h-px w-full bg-border" />
|
||||
|
||||
{/* Other ways */}
|
||||
<div className="mt-10">
|
||||
<h3 className="text-center text-xl font-semibold">No budget? No problem—here’s how to help for free</h3>
|
||||
<div className="mx-auto mt-4 max-w-3xl">
|
||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
"Share our videos with a friend",
|
||||
"Star + share our repos/tools",
|
||||
"Submit laser settings for others",
|
||||
"Report bugs and suggest features",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HandCoins className="h-4 w-4" aria-hidden="true" /> {item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Closing statement */}
|
||||
<div className="mx-auto mt-10 max-w-3xl text-center">
|
||||
<p className="text-balance text-sm text-muted-foreground">
|
||||
Laser Everything exists because makers before you chose to keep the ladder down. If we each do a small part —
|
||||
<span className="font-medium text-foreground"> we never need a paywall</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
app/app/portal/projects/Client.tsx
Normal file
38
app/app/portal/projects/Client.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
const ProjectsList = dynamic(() => import("@/app/projects/page"), { ssr: false });
|
||||
|
||||
function DetailsFrame({ src }: { src: string }) {
|
||||
return (
|
||||
<iframe
|
||||
key={src}
|
||||
src={src}
|
||||
className="w-full h-[70vh] rounded-md border"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectsClient() {
|
||||
const search = useSearchParams();
|
||||
const id = search.get("id");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Projects</h2>
|
||||
<ProjectsList />
|
||||
</div>
|
||||
|
||||
{id && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-2 text-sm opacity-70">Viewing project #{id}</div>
|
||||
<DetailsFrame src={`/projects/${id}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/projects/page.tsx
Normal file
8
app/app/portal/projects/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// app/portal/projects/page.tsx
|
||||
import ProjectsClient from "./Client";
|
||||
|
||||
export const metadata = { title: "MakerDash • Projects" };
|
||||
|
||||
export default function Page() {
|
||||
return <ProjectsClient />;
|
||||
}
|
||||
46
app/app/portal/rigs/page.tsx
Normal file
46
app/app/portal/rigs/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// app/portal/rigs/page.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import RigsSwitcher from "@/components/portal/RigsSwitcher";
|
||||
|
||||
type Opt = { id: string | number; label: string };
|
||||
|
||||
export default async function Page() {
|
||||
const jar = await cookies(); // Next 15: async cookies()
|
||||
const ma_at = jar.get("ma_at")?.value;
|
||||
if (!ma_at) redirect("/auth/sign-in?next=/portal/rigs");
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
let rigTypes: Opt[] = [];
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${DIRECTUS}/items/user_rig_type?fields=id,name&sort=sort`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ma_at}`,
|
||||
Accept: "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
rigTypes = (json?.data ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
label: r.name ?? String(r.id),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// fall back to empty
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Rigs</h2>
|
||||
<RigsSwitcher rigTypes={rigTypes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/app/portal/utilities/Client.tsx
Normal file
19
app/app/portal/utilities/Client.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// app/portal/utilities/Client.tsx
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const UtilitySwitcher = dynamic(() => import("@/components/portal/UtilitySwitcher"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function UtilitiesClient() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">Utilities</h2>
|
||||
<UtilitySwitcher />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/app/portal/utilities/page.tsx
Normal file
8
app/app/portal/utilities/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// app/portal/utilities/page.tsx
|
||||
import UtilitiesClient from "./Client";
|
||||
|
||||
export const metadata = { title: "MakerDash • Utilities" };
|
||||
|
||||
export default function Page() {
|
||||
return <UtilitiesClient />;
|
||||
}
|
||||
2
app/app/projects/[id]/page.tsx
Normal file
2
app/app/projects/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/projects/[id]/page.tsx
|
||||
export { default } from "./projects";
|
||||
153
app/app/projects/[id]/projects.tsx
Normal file
153
app/app/projects/[id]/projects.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [project, setProject] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/projects/${id}`);
|
||||
url.searchParams.set(
|
||||
"fields",
|
||||
"submission_id,title,uploader,category,tags,p_image.filename_disk,p_image.title,p_files.directus_files_id.filename_disk"
|
||||
);
|
||||
url.searchParams.set("limit", "1");
|
||||
|
||||
fetch(url.toString())
|
||||
.then((res) => res.json())
|
||||
.then((data) => setProject(data.data))
|
||||
.catch(() => setProject(null));
|
||||
}, [id]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link href="/projects" className="text-accent underline">
|
||||
← Back to Projects
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Loading project…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const imageSrc = project.p_image?.filename_disk
|
||||
? `${process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net"}/assets/${project.p_image.filename_disk}`
|
||||
: null;
|
||||
|
||||
const fileList: string[] = Array.isArray(project.p_files)
|
||||
? project.p_files
|
||||
.map((f: any) => f?.directus_files_id?.filename_disk)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
<style jsx global>{`
|
||||
.file-pill {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
border-radius: 0.25rem;
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.file-pill:hover {
|
||||
background-color: #ffde59;
|
||||
color: #000;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="mb-4">
|
||||
<Link href="/projects" className="text-accent underline">
|
||||
← Back to Projects
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-1">
|
||||
<div className="border rounded overflow-hidden bg-card">
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={project.p_image?.title || "Project image"}
|
||||
width={800}
|
||||
height={800}
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6 text-sm text-muted-foreground">No preview image</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-semibold mb-2">Files</h3>
|
||||
{fileList.length > 0 ? (
|
||||
<div className="flex flex-wrap">
|
||||
{fileList.map((fname, i) => (
|
||||
<a
|
||||
key={i}
|
||||
className="file-pill"
|
||||
href={`${process.env.NEXT_PUBLIC_ASSET_URL || "https://forms.lasereverything.net"}/assets/${fname}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={fname}
|
||||
>
|
||||
{fname}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No files attached.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<h1 className="text-2xl font-bold mb-2">{project.title}</h1>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Uploaded by: {project.uploader || "—"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Category: {project.category || "—"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.isArray(project.tags) && project.tags.length > 0 ? (
|
||||
project.tags.map((tag, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={`/projects?query=${encodeURIComponent(tag)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs bg-muted text-foreground rounded px-2 py-0.5 hover:bg-accent hover:text-background transition-colors"
|
||||
title={`Search for ${tag}`}
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional long description if you add one later */}
|
||||
{project.body ? (
|
||||
<div className="prose dark:prose-invert mt-6">
|
||||
<ReactMarkdown>{project.body}</ReactMarkdown>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
app/app/projects/layout.tsx
Normal file
4
app/app/projects/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
364
app/app/projects/page.tsx
Normal file
364
app/app/projects/page.tsx
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
type ProjectRow = {
|
||||
id?: string | number;
|
||||
submission_id?: string | number;
|
||||
title?: string;
|
||||
uploader?: string;
|
||||
category?: string;
|
||||
tags?: string[] | string | null;
|
||||
p_image?: { filename_disk?: string; title?: string } | null;
|
||||
};
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialQuery = searchParams.get("query") || "";
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const [projects, setProjects] = useState<ProjectRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categories] = useState([
|
||||
"assets",
|
||||
"documents",
|
||||
"fixtures",
|
||||
"projects",
|
||||
"templates",
|
||||
"test files",
|
||||
"tools",
|
||||
]);
|
||||
|
||||
// simple debounce
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
// fetch EXACTLY like the working pre-patch version
|
||||
useEffect(() => {
|
||||
const url = new URL(`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/projects`);
|
||||
url.searchParams.set(
|
||||
"fields",
|
||||
"submission_id,title,uploader,category,tags,p_image.filename_disk,p_image.title"
|
||||
);
|
||||
url.searchParams.set("limit", "-1");
|
||||
|
||||
fetch(url.toString(), { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const list = Array.isArray(data?.data) ? (data.data as ProjectRow[]) : [];
|
||||
setProjects(list);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const highlight = (text: string) => {
|
||||
if (!debouncedQuery) return text;
|
||||
const regex = new RegExp(`(${debouncedQuery})`, "gi");
|
||||
return text?.replace(regex, "<mark>$1</mark>");
|
||||
};
|
||||
|
||||
const normalize = (str: unknown) =>
|
||||
String(str ?? "").toLowerCase().replace(/[_\s]/g, "");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = normalize(debouncedQuery);
|
||||
return projects.filter((entry) => {
|
||||
const tags =
|
||||
Array.isArray(entry.tags)
|
||||
? entry.tags
|
||||
: typeof entry.tags === "string"
|
||||
? entry.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const fieldsToSearch = [
|
||||
entry.title ?? "",
|
||||
entry.uploader ?? "",
|
||||
entry.category ?? "",
|
||||
tags.join(" "),
|
||||
];
|
||||
|
||||
return fieldsToSearch
|
||||
.filter(Boolean)
|
||||
.some((field) => normalize(field).includes(q));
|
||||
});
|
||||
}, [projects, debouncedQuery]);
|
||||
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
projects.forEach((p) => {
|
||||
const tags =
|
||||
Array.isArray(p.tags)
|
||||
? p.tags
|
||||
: typeof p.tags === "string"
|
||||
? p.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
tags.forEach((tag) => {
|
||||
counts[tag] = (counts[tag] || 0) + 1;
|
||||
});
|
||||
});
|
||||
return counts;
|
||||
}, [projects]);
|
||||
|
||||
const popularTags = Object.entries(tagCounts)
|
||||
.sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0))
|
||||
.slice(0, 10)
|
||||
.map(([tag]) => tag);
|
||||
|
||||
const recentTags = [...projects]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.submission_id ?? 0) - Number(a.submission_id ?? 0)
|
||||
)
|
||||
.slice(0, 10)
|
||||
.flatMap((p) => {
|
||||
const tags =
|
||||
Array.isArray(p.tags)
|
||||
? p.tags
|
||||
: typeof p.tags === "string"
|
||||
? p.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
return tags;
|
||||
})
|
||||
.filter((tag, i, self) => self.indexOf(tag) === i)
|
||||
.slice(0, 10);
|
||||
|
||||
const uniqueUploaders = new Set(
|
||||
projects.map((p) => p.uploader).filter(Boolean)
|
||||
).size;
|
||||
const totalTags = Object.keys(tagCounts).length;
|
||||
|
||||
const detailHref = (p: ProjectRow) => `/projects/${p.submission_id}`;
|
||||
const imageSrc = (p: ProjectRow) =>
|
||||
p.p_image?.filename_disk
|
||||
? `https://forms.lasereverything.net/assets/${p.p_image.filename_disk}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<style jsx global>{`
|
||||
mark {
|
||||
background: #ffde59;
|
||||
color: #242424;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.project-card {
|
||||
display: flex;
|
||||
background-color: #242424;
|
||||
color: var(--card-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
height: 150px;
|
||||
}
|
||||
.project-image {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
}
|
||||
.project-content {
|
||||
padding: 0.75rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.project-tags {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-tags span,
|
||||
.flex.flex-wrap span {
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--muted);
|
||||
color: var(--foreground);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.project-tags span:hover,
|
||||
.flex.flex-wrap span:hover {
|
||||
background-color: #ffde59;
|
||||
color: #000;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="grid md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h1 className="text-xl font-bold mb-2">Community Projects</h1>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search projects..."
|
||||
className="w-full dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block mt-4 px-4 py-2 bg-accent text-background rounded-md text-sm"
|
||||
>
|
||||
← Back to Main Menu
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Popular Tags</h2>
|
||||
{popularTags.length > 0 ? (
|
||||
<div className="flex flex-wrap">
|
||||
{popularTags.map((tag, i) => (
|
||||
<span key={i} onClick={() => setQuery(tag)} title={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No tags yet.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Recent Tags</h2>
|
||||
{recentTags.length > 0 ? (
|
||||
<div className="flex flex-wrap">
|
||||
{recentTags.map((tag, i) => (
|
||||
<span key={i} onClick={() => setQuery(tag)} title={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No recent tags.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Project Stats</h2>
|
||||
<p className="text-sm text-muted-foreground">Total Projects: {projects.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Unique Uploaders: {uniqueUploaders}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Tags: {totalTags}</p>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-md font-semibold mb-2">Browse by Category</h2>
|
||||
<div className="flex flex-wrap">
|
||||
{categories.map((cat, i) => (
|
||||
<span key={i} onClick={() => setQuery(cat)}>
|
||||
{cat === "test_files" ? "test files" : cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<h2 className="text-md font-semibold mb-2">Submit a Project</h2>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Have a cool design, tool, or jig to share? Submit it to the community database.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className="bg-muted text-foreground text-sm px-4 py-2 rounded opacity-50 cursor-not-allowed"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-8 border-border" />
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading projects...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted">No projects found.</p>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{filtered.map((project) => {
|
||||
const key = String(project.submission_id ?? project.id ?? Math.random());
|
||||
const href = detailHref(project);
|
||||
const src = imageSrc(project);
|
||||
return (
|
||||
<div key={key} className="project-card">
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={project.p_image?.title || "Project image"}
|
||||
width={150}
|
||||
height={150}
|
||||
className="project-image"
|
||||
/>
|
||||
) : (
|
||||
<div className="project-image flex items-center justify-center text-xs text-muted-foreground">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
<div className="project-content">
|
||||
<div>
|
||||
<Link href={href} className="text-base font-semibold text-accent underline">
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(project.title || "Untitled"),
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uploaded by:{" "}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(project.uploader || "—"),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Category:{" "}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(project.category || "—"),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="project-tags">
|
||||
{(() => {
|
||||
const tags =
|
||||
Array.isArray(project.tags)
|
||||
? project.tags
|
||||
: typeof project.tags === "string"
|
||||
? project.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: [];
|
||||
return tags.length
|
||||
? tags.map((tag, i) => (
|
||||
<span key={i} onClick={() => setQuery(tag)} title={tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
: "";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
app/app/rigs/RigBuilderClient.tsx
Normal file
372
app/app/rigs/RigBuilderClient.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Opt = { id: string | number; label: string };
|
||||
|
||||
const API = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
||||
|
||||
// helper: render multipliers like "06x", "8x", "1.5x"; avoid double "x"
|
||||
function formatMultiplier(raw: any) {
|
||||
const s = String(raw ?? "").trim();
|
||||
if (!s) return "—";
|
||||
if (/x$/i.test(s)) return s; // already has x
|
||||
// pure number?
|
||||
if (/^\d+(\.\d+)?$/.test(s)) {
|
||||
if (/^\d$/.test(s)) return `0${s}x`;
|
||||
return `${s}x`;
|
||||
}
|
||||
// otherwise, extract first number if present
|
||||
const m = s.match(/(\d+(?:\.\d+)?)/);
|
||||
if (m) {
|
||||
const n = m[1];
|
||||
if (/^\d$/.test(n)) return `0${n}x`;
|
||||
return `${n}x`;
|
||||
}
|
||||
return `${s}x`;
|
||||
}
|
||||
|
||||
// tiny helper to ensure we always iterate an array
|
||||
function toArray<T = any>(v: any): T[] {
|
||||
return Array.isArray(v) ? (v as T[]) : [];
|
||||
}
|
||||
|
||||
function useOptions(
|
||||
kind: "laser_software" | "laser_source" | "lens" | "scan_lens_apt" | "scan_lens_exp",
|
||||
targetKey?: string
|
||||
) {
|
||||
const [opts, setOpts] = useState<Opt[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
let url = "";
|
||||
// default normalize
|
||||
let normalize = (rows: any[]): Opt[] =>
|
||||
rows.map((r) => ({
|
||||
id: String(r.id ?? r.submission_id ?? ""),
|
||||
label: String(r.name ?? r.label ?? r.title ?? r.model ?? r.id ?? ""),
|
||||
}));
|
||||
|
||||
if (kind === "laser_software") {
|
||||
url = `${API}/items/laser_software?fields=id,name&limit=1000&sort=name`;
|
||||
} else if (kind === "laser_source") {
|
||||
// fetch all sources; client filter by nm band from targetKey
|
||||
url = `${API}/items/laser_source?fields=submission_id,make,model,nm&limit=2000&sort=make,model`;
|
||||
const parseNum = (v: any) => {
|
||||
if (v == null) return null;
|
||||
const m = String(v).match(/(\d+(\.\d+)?)/);
|
||||
return m ? Number(m[1]) : null;
|
||||
};
|
||||
const nmRange = (t?: string | null): [number, number] | null => {
|
||||
if (!t) return null;
|
||||
const s = t.toLowerCase();
|
||||
if (s.includes("fiber")) return [1000, 9000];
|
||||
if (s.includes("uv")) return [300, 400];
|
||||
if (s.includes("gantry") || s.includes("co2 gantry") || s.includes("co₂ gantry"))
|
||||
return [10000, 11000];
|
||||
if (s.includes("galvo") || s.includes("co2 galvo") || s.includes("co₂ galvo"))
|
||||
return [10000, 11000];
|
||||
return null;
|
||||
};
|
||||
const range = nmRange(targetKey);
|
||||
normalize = (rows) => {
|
||||
const filtered = range
|
||||
? rows.filter((r: any) => {
|
||||
const nm = parseNum(r.nm);
|
||||
return nm != null && nm >= range[0] && nm <= range[1];
|
||||
})
|
||||
: rows;
|
||||
return filtered.map((r: any) => ({
|
||||
id: String(r.submission_id ?? ""),
|
||||
label: [r.make, r.model].filter(Boolean).join(" ") || String(r.submission_id ?? ""),
|
||||
}));
|
||||
};
|
||||
} else if (kind === "lens") {
|
||||
if (targetKey && targetKey.toLowerCase().includes("gantry")) {
|
||||
url = `${API}/items/laser_focus_lens?fields=id,name&limit=1000&sort=name`;
|
||||
} else {
|
||||
url = `${API}/items/laser_scan_lens?fields=id,field_size,focal_length&limit=1000`;
|
||||
normalize = (rows) => {
|
||||
const toNum = (v: any) => {
|
||||
const m = String(v ?? "").match(/-?\d+(\.\d+)?/);
|
||||
return m ? parseFloat(m[0]) : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
return [...rows]
|
||||
.sort((a, b) => toNum(a.focal_length) - toNum(b.focal_length))
|
||||
.map((r) => ({
|
||||
id: String(r.id ?? ""),
|
||||
label:
|
||||
[r.field_size && `${r.field_size} mm`, r.focal_length && `${r.focal_length} mm`]
|
||||
.filter(Boolean)
|
||||
.join(" — ") || String(r.id ?? ""),
|
||||
}));
|
||||
};
|
||||
}
|
||||
} else if (kind === "scan_lens_apt") {
|
||||
url = `${API}/items/laser_scan_lens_apt?fields=id,name&limit=1000&sort=name`;
|
||||
} else if (kind === "scan_lens_exp") {
|
||||
url = `${API}/items/laser_scan_lens_exp?fields=id,name&limit=1000&sort=name`;
|
||||
normalize = (rows) =>
|
||||
rows.map((r: any) => ({
|
||||
id: String(r.id ?? ""),
|
||||
label: formatMultiplier(r.name ?? r.label ?? r.id ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
const res = await fetch(url, { credentials: "include", cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${kind}`);
|
||||
const json = await res.json().catch(() => ({} as any));
|
||||
const rows = toArray(json?.data);
|
||||
|
||||
let mapped: Opt[] = [];
|
||||
try {
|
||||
mapped = toArray(normalize(rows));
|
||||
} catch {
|
||||
// Safe fallback: minimal id/label mapping to prevent render crashes
|
||||
mapped = rows.map((r: any) => ({
|
||||
id: String(r?.id ?? r?.submission_id ?? ""),
|
||||
label: String(r?.name ?? r?.label ?? r?.title ?? r?.model ?? r?.id ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
if (alive) setOpts(mapped);
|
||||
} catch (_err) {
|
||||
if (alive) setOpts([]); // swallow errors → empty list; form still renders
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [kind, targetKey]);
|
||||
|
||||
return { opts, loading };
|
||||
}
|
||||
|
||||
export default function RigBuilderClient({ rigTypes }: { rigTypes: Opt[] }) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [rigType, setRigType] = useState<string>("");
|
||||
const [laserSource, setLaserSource] = useState<string>("");
|
||||
const [scanLens, setScanLens] = useState<string>("");
|
||||
const [scanLensApt, setScanLensApt] = useState<string>(""); // galvo-only
|
||||
const [scanLensExp, setScanLensExp] = useState<string>(""); // galvo-only
|
||||
const [focusLens, setFocusLens] = useState<string>("");
|
||||
const [software, setSoftware] = useState<string>("");
|
||||
|
||||
// rigTypes come from the SERVER as props.
|
||||
const targetKey = useMemo(() => {
|
||||
const rt = rigTypes.find((o) => String(o.id) === String(rigType))?.label || "";
|
||||
return rt;
|
||||
}, [rigTypes, rigType]);
|
||||
|
||||
const sources = useOptions("laser_source", targetKey);
|
||||
const lens = useOptions("lens", targetKey);
|
||||
const lensApt = useOptions("scan_lens_apt");
|
||||
const lensExp = useOptions("scan_lens_exp");
|
||||
const softwares = useOptions("laser_software");
|
||||
|
||||
const isGantry = (targetKey || "").toLowerCase().includes("gantry");
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const body: any = {
|
||||
name,
|
||||
rig_type: rigType ? Number(rigType) : null,
|
||||
laser_source: laserSource ? Number(laserSource) : null,
|
||||
notes: notes || "",
|
||||
laser_software: software ? Number(software) : null,
|
||||
};
|
||||
if (isGantry) {
|
||||
body.laser_focus_lens = focusLens ? Number(focusLens) : null;
|
||||
body.laser_scan_lens = null;
|
||||
body.laser_scan_lens_apt = null;
|
||||
body.laser_scan_lens_exp = null;
|
||||
} else {
|
||||
body.laser_scan_lens = scanLens ? Number(scanLens) : null;
|
||||
body.laser_focus_lens = null;
|
||||
body.laser_scan_lens_apt = scanLensApt ? Number(scanLensApt) : null;
|
||||
body.laser_scan_lens_exp = scanLensExp ? Number(scanLensExp) : null;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/rigs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(j?.error || "Failed to create rig");
|
||||
return;
|
||||
}
|
||||
router.replace("/portal/rigs?t=my", { scroll: false });
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4 max-w-xl">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">
|
||||
Rig Name <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">
|
||||
Rig Type <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={rigType}
|
||||
onChange={(e) => setRigType(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">—</option>
|
||||
{rigTypes.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">
|
||||
Laser Source <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={laserSource}
|
||||
onChange={(e) => setLaserSource(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">{sources.loading ? "Loading…" : "—"}</option>
|
||||
{sources.opts.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lens (focus for gantry, scan + apt/exp for others) */}
|
||||
{isGantry ? (
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Focus Lens</label>
|
||||
<select
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={focusLens}
|
||||
onChange={(e) => setFocusLens(e.target.value)}
|
||||
>
|
||||
<option value="">{lens.loading ? "Loading…" : "—"}</option>
|
||||
{lens.opts.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Scan Lens</label>
|
||||
<select
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={scanLens}
|
||||
onChange={(e) => setScanLens(e.target.value)}
|
||||
>
|
||||
<option value="">{lens.loading ? "Loading…" : "—"}</option>
|
||||
{lens.opts.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Scan Lens Apt</label>
|
||||
<select
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={scanLensApt}
|
||||
onChange={(e) => setScanLensApt(e.target.value)}
|
||||
>
|
||||
<option value="">{lensApt.loading ? "Loading…" : "—"}</option>
|
||||
{lensApt.opts.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Scan Lens Exp</label>
|
||||
<select
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={scanLensExp}
|
||||
onChange={(e) => setScanLensExp(e.target.value)}
|
||||
>
|
||||
<option value="">{lensExp.loading ? "Loading…" : "—"}</option>
|
||||
{lensExp.opts.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Software</label>
|
||||
<select
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={software}
|
||||
onChange={(e) => setSoftware(e.target.value)}
|
||||
>
|
||||
<option value="">{softwares.loading ? "Loading…" : "—"}</option>
|
||||
{softwares.opts.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Notes</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
className="w-full border rounded px-2 py-1"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="px-3 py-2 border rounded bg-accent text-background hover:opacity-90">
|
||||
Create Rig
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
43
app/app/rigs/RigBuilderServer.tsx
Normal file
43
app/app/rigs/RigBuilderServer.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// app/rigs/RigBuilderServer.tsx
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import RigBuilderClient from "./RigBuilderClient";
|
||||
|
||||
type Opt = { id: string | number; label: string };
|
||||
|
||||
export default async function RigBuilderServer() {
|
||||
const jar = await cookies(); // <-- await in Next 15
|
||||
const ma_at = jar.get("ma_at")?.value;
|
||||
if (!ma_at) {
|
||||
const next = encodeURIComponent("/rigs");
|
||||
redirect(`/auth/sign-in?next=${next}`);
|
||||
}
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const res = await fetch(
|
||||
`${DIRECTUS}/items/user_rig_type?fields=id,name&sort=sort`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ma_at}`,
|
||||
Accept: "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to load user_rig_type (${res.status}): ${text || res.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const json = await res.json().catch(() => ({ data: [] as any[] }));
|
||||
const rigTypes: Opt[] = (json?.data ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
label: r.name ?? String(r.id),
|
||||
}));
|
||||
|
||||
return <RigBuilderClient rigTypes={rigTypes} />;
|
||||
}
|
||||
89
app/app/rigs/RigsListClient.tsx
Normal file
89
app/app/rigs/RigsListClient.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Rig = {
|
||||
id: string | number;
|
||||
name: string;
|
||||
notes?: string;
|
||||
rig_type?: { id: number; name: string };
|
||||
laser_source?: { submission_id: number; make?: string; model?: string };
|
||||
laser_scan_lens?: { id: number; field_size?: string; focal_length?: string };
|
||||
laser_focus_lens?: { id: number; name?: string };
|
||||
laser_scan_lens_apt?: { id: number; name?: string };
|
||||
laser_scan_lens_exp?: { id: number; name?: string };
|
||||
laser_software?: { id: number; name?: string };
|
||||
};
|
||||
|
||||
function fmtX(v?: string) {
|
||||
if (!v) return "";
|
||||
const s = String(v).trim();
|
||||
if (/x$/i.test(s)) return s;
|
||||
if (/^\d+(\.\d+)?$/.test(s)) {
|
||||
if (/^\d$/.test(s)) return `0${s}x`;
|
||||
return `${s}x`;
|
||||
}
|
||||
const m = s.match(/(\d+(?:\.\d+)?)/);
|
||||
if (m) {
|
||||
const n = m[1];
|
||||
if (/^\d$/.test(n)) return `0${n}x`;
|
||||
return `${n}x`;
|
||||
}
|
||||
return `${s}x`;
|
||||
}
|
||||
|
||||
export default function RigsListClient() {
|
||||
const [rows, setRows] = useState<Rig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/rigs", { credentials: "include", cache: "no-store" });
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data) ? data : data?.data ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function remove(id: string | number) {
|
||||
if (!confirm("Delete this rig?")) return;
|
||||
const res = await fetch(`/api/rigs?id=${encodeURIComponent(String(id))}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.ok) load();
|
||||
else alert("Failed to delete");
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
||||
if (!rows.length) return <div className="text-sm opacity-70">No rigs yet.</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="rounded border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium">{r.name}</div>
|
||||
<button onClick={() => remove(r.id)} className="text-sm px-2 py-1 border rounded hover:bg-muted">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{r.rig_type?.name ? <>Type: {r.rig_type.name}. </> : null}
|
||||
{r.laser_source ? <>Source: {[r.laser_source.make, r.laser_source.model].filter(Boolean).join(" ") || r.laser_source.submission_id}. </> : null}
|
||||
{r.laser_focus_lens?.name ? <>Focus Lens: {r.laser_focus_lens.name}. </> : null}
|
||||
{r.laser_scan_lens ? <>Scan Lens: {[r.laser_scan_lens.field_size && `${r.laser_scan_lens.field_size}mm`, r.laser_scan_lens.focal_length && `${r.laser_scan_lens.focal_length}mm`].filter(Boolean).join(" / ")}. </> : null}
|
||||
{r.laser_scan_lens_apt?.name ? <>Scan Lens Apt: {r.laser_scan_lens_apt.name}. </> : null}
|
||||
{r.laser_scan_lens_exp?.name ? <>Scan Lens Exp: {fmtX(r.laser_scan_lens_exp.name)}. </> : null}
|
||||
{r.laser_software?.name ? <>Software: {r.laser_software.name}. </> : null}
|
||||
</div>
|
||||
{r.notes ? <div className="text-sm mt-2">{r.notes}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
app/app/rigs/page.tsx
Normal file
30
app/app/rigs/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// app/rigs/page.tsx
|
||||
import RigBuilderServer from "./RigBuilderServer";
|
||||
import RigsListClient from "./RigsListClient";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="p-4 space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold mb-1">Rigs</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage rigs used when submitting settings.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Left: existing rigs */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-2">My Rigs</h2>
|
||||
<RigsListClient />
|
||||
</section>
|
||||
|
||||
{/* Right: create a new rig (server-provided rig types) */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-2">Create Rig</h2>
|
||||
<RigBuilderServer />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
app/app/settings/co2-gantry/[id]/co2-gantry.tsx
Normal file
368
app/app/settings/co2-gantry/[id]/co2-gantry.tsx
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export default function CO2GantrySettingDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [setting, setSetting] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// claim UI state
|
||||
const [claimBusy, setClaimBusy] = useState(false);
|
||||
const [claimMsg, setClaimMsg] = useState<string | null>(null);
|
||||
const [claimErr, setClaimErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const url =
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gan/${id}` +
|
||||
`?fields=` +
|
||||
[
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"uploader",
|
||||
// owner (M2O) — include username explicitly
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
"owner.first_name",
|
||||
"owner.last_name",
|
||||
"owner.email",
|
||||
// content & assets
|
||||
"setting_notes",
|
||||
"photo.filename_disk",
|
||||
"photo.title",
|
||||
"screen.filename_disk",
|
||||
"screen.title",
|
||||
// denormalized relations
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"mat_color.name",
|
||||
"mat_opacity.opacity",
|
||||
"mat_thickness",
|
||||
"source.make",
|
||||
"source.model",
|
||||
// NOTE: laser_soft is a STRING field (not relation)
|
||||
"laser_soft",
|
||||
// gantry uses lens.name (not field_size)
|
||||
"lens.name",
|
||||
"lens_conf.name",
|
||||
// misc fields
|
||||
"focus",
|
||||
"repeat_all",
|
||||
"fill_settings",
|
||||
"line_settings",
|
||||
"raster_settings",
|
||||
].join(",");
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to load");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSetting(data.data);
|
||||
})
|
||||
.catch(() => setSetting(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div className="p-6 max-w-7xl mx-auto">Loading…</div>;
|
||||
if (!setting) return <div className="p-6 max-w-7xl mx-auto">Setting not found.</div>;
|
||||
|
||||
// Prefer owner's username per schema; return null when absent so claim UI shows
|
||||
const ownerName = (row: any) => {
|
||||
const o = row?.owner;
|
||||
if (!o) return null;
|
||||
return o.username || null;
|
||||
};
|
||||
|
||||
const formatBoolean = (val: any) =>
|
||||
val ? "Enabled" : val === false ? "Disabled" : "—";
|
||||
|
||||
const renderRepeaterCard = (title: string, fields: any[], data: any[]) => {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
||||
const filtered = data.filter((item) =>
|
||||
Object.values(item).some((v) => v !== null && v !== "")
|
||||
);
|
||||
if (filtered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
{filtered.map((item, i) => (
|
||||
<div key={i} className="border border-border rounded-lg p-4 bg-card">
|
||||
{fields.map(({ key, label, condition }: any) => {
|
||||
if (condition && !condition(item)) return null;
|
||||
const value = item[key];
|
||||
return (
|
||||
<p key={key} className="text-sm">
|
||||
<strong>{label}:</strong>{" "}
|
||||
{typeof value === "boolean" ? formatBoolean(value) : value ?? "—"}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openSearchInNewTab = (value: string) => {
|
||||
if (!value || typeof window === "undefined") return;
|
||||
const url = new URL("/settings/co2-gantry", window.location.origin);
|
||||
url.searchParams.set("query", value);
|
||||
window.open(url.toString(), "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const onClaim = async () => {
|
||||
setClaimBusy(true);
|
||||
setClaimErr(null);
|
||||
setClaimMsg(null);
|
||||
try {
|
||||
const r = await fetch("/api/claims", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target_collection: "settings_co2gan",
|
||||
target_id: id,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
data?.error || data?.errors?.[0]?.message || "Failed to submit claim"
|
||||
);
|
||||
}
|
||||
setClaimMsg("Claim request submitted for review.");
|
||||
} catch (e: any) {
|
||||
setClaimErr(e?.message || "Failed to submit claim");
|
||||
} finally {
|
||||
setClaimBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const owner = ownerName(setting);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{/* Title / Meta / Ownership */}
|
||||
<div className="card bg-card p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-1">{setting.setting_title}</h1>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground mb-3">
|
||||
<p>
|
||||
<strong>Owner:</strong>{" "}
|
||||
{owner ? <span>{owner}</span> : <span>—</span>}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Uploader:</strong> {setting.uploader || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!owner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClaim}
|
||||
disabled={claimBusy}
|
||||
className="px-3 py-1.5 rounded bg-accent text-background text-sm disabled:opacity-60"
|
||||
>
|
||||
{claimBusy ? "Submitting…" : "Claim this setting"}
|
||||
</button>
|
||||
{claimMsg && (
|
||||
<span className="text-green-500 text-sm">{claimMsg}</span>
|
||||
)}
|
||||
{claimErr && (
|
||||
<span className="text-red-500 text-sm">{claimErr}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/settings/co2-gantry"
|
||||
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
|
||||
>
|
||||
← Back to CO₂ Gantry Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Result Photo + Material */}
|
||||
<div className="card bg-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="flex justify-center">
|
||||
{setting.photo?.filename_disk && (
|
||||
<a
|
||||
href={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
|
||||
alt={setting.photo?.title || "Preview"}
|
||||
width={250}
|
||||
height={250}
|
||||
className="rounded object-contain max-w-[250px] max-h-[250px]"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Material</h2>
|
||||
<p>
|
||||
<strong>Material:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.mat?.name)}
|
||||
>
|
||||
{setting.mat?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Coating:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.mat_coat?.name)}
|
||||
>
|
||||
{setting.mat_coat?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Color:</strong> {setting.mat_color?.name || "—"}</p>
|
||||
<p><strong>Opacity:</strong> {setting.mat_opacity?.opacity || "—"}</p>
|
||||
<p>
|
||||
<strong>Thickness:</strong>{" "}
|
||||
{setting.mat_thickness ? `${setting.mat_thickness} mm` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup */}
|
||||
<div className="card bg-card p-4">
|
||||
<h2 className="text-xl font-semibold mb-2">Setup</h2>
|
||||
{/* laser_soft is a string field */}
|
||||
<p><strong>Software:</strong> {setting.laser_soft || "—"}</p>
|
||||
<p><strong>Repeat All (global):</strong> {setting.repeat_all ?? "—"}</p>
|
||||
<p className="mt-4"><strong>Focus:</strong> {setting.focus ?? "—"} mm</p>
|
||||
<small>-Values Focus Closer | +Values Focus Further</small>
|
||||
</div>
|
||||
|
||||
{/* Laser (screenshot left, specs right; heading aligned above specs) */}
|
||||
<div className="card bg-card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="flex justify-center">
|
||||
{setting.screen?.filename_disk ? (
|
||||
<a
|
||||
href={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={setting.screen?.title || "Screenshot"}
|
||||
>
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
|
||||
alt={setting.screen?.title || "Screenshot"}
|
||||
width={250}
|
||||
height={250}
|
||||
className="rounded object-contain max-w-[250px] max-h-[250px]"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No screenshot</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Laser</h2>
|
||||
<p><strong>Source Make:</strong> {setting.source?.make || "—"}</p>
|
||||
<p>
|
||||
<strong>Source Model:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.source?.model)}
|
||||
>
|
||||
{setting.source?.model || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Lens:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.lens?.name)}
|
||||
>
|
||||
{setting.lens?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Lens Config:</strong> {setting.lens_conf?.name || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="card bg-card p-4 mt-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Notes</h2>
|
||||
<div className="prose dark:prose-invert">
|
||||
<Markdown>{setting.setting_notes || "—"}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-6 border-muted" />
|
||||
|
||||
{/* Repeaters (unchanged fields per your original) */}
|
||||
{renderRepeaterCard(
|
||||
"Fill Settings",
|
||||
[
|
||||
{ key: "name", label: "Fill Name" },
|
||||
{ key: "power", label: "Power (%)" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "interval", label: "Interval (mm)" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "flood", label: "Flood Fill" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.fill_settings
|
||||
)}
|
||||
|
||||
{renderRepeaterCard(
|
||||
"Line Settings",
|
||||
[
|
||||
{ key: "name", label: "Line Name" },
|
||||
{ key: "power", label: "Power (%)" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "perf", label: "Perforation" },
|
||||
{ key: "cut", label: "Cut Power Override" },
|
||||
{ key: "skip", label: "Skip Pass" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.line_settings
|
||||
)}
|
||||
|
||||
{renderRepeaterCard(
|
||||
"Raster Settings",
|
||||
[
|
||||
{ key: "name", label: "Raster Name" },
|
||||
{ key: "power", label: "Power (%)" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "dither", label: "Dither" },
|
||||
{ key: "halftone_cell", label: "Halftone Cell" },
|
||||
{ key: "halftone_angle", label: "Angle" },
|
||||
{ key: "inversion", label: "Inversion" },
|
||||
{ key: "interval", label: "Interval" },
|
||||
{ key: "dot", label: "Dot Size" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.raster_settings
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
app/app/settings/co2-gantry/[id]/page.tsx
Normal file
2
app/app/settings/co2-gantry/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/settings/co2-gantry/[id]/page.tsx
|
||||
export { default } from "./co2-gantry";
|
||||
4
app/app/settings/co2-gantry/layout.tsx
Normal file
4
app/app/settings/co2-gantry/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
312
app/app/settings/co2-gantry/page.tsx
Normal file
312
app/app/settings/co2-gantry/page.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
type Owner = {
|
||||
username?: string | null;
|
||||
id?: string | number;
|
||||
// keep extras harmlessly in case API returns them
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export default function CO2GantrySettingsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialQuery = searchParams.get("query") || "";
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const [settings, setSettings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const detailHref = (id: string | number) => `/settings/co2-gantry/${id}`;
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const url =
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_co2gan` +
|
||||
`?fields=` +
|
||||
[
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"uploader",
|
||||
// owner (M2O) – ensure username is requested
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
// assets / denorms
|
||||
"photo.id",
|
||||
"photo.title",
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"source.model",
|
||||
// NOTE: gantry uses lens.name (not field_size)
|
||||
"lens.name",
|
||||
].join(",") +
|
||||
`&limit=-1`;
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setSettings(data?.data || []))
|
||||
.catch((e) => {
|
||||
console.error("CO2 Gantry settings fetch failed:", e);
|
||||
setSettings([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const ownerLabel = (o?: Owner) => (o?.username ?? "—");
|
||||
|
||||
const highlight = (text?: string) => {
|
||||
if (!debouncedQuery) return text || "";
|
||||
const regex = new RegExp(`(${debouncedQuery})`, "gi");
|
||||
return (text || "").replace(regex, "<mark>$1</mark>");
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return settings.filter((entry) => {
|
||||
const fieldsToSearch = [
|
||||
entry.setting_title,
|
||||
ownerLabel(entry.owner),
|
||||
entry.uploader, // keep legacy column visible for now
|
||||
entry.mat?.name,
|
||||
entry.mat_coat?.name,
|
||||
entry.source?.model,
|
||||
entry.lens?.name,
|
||||
];
|
||||
return fieldsToSearch
|
||||
.filter(Boolean)
|
||||
.some((field: string) => String(field).toLowerCase().includes(q));
|
||||
});
|
||||
}, [settings, debouncedQuery]);
|
||||
|
||||
const total = settings.length;
|
||||
const uniqueMaterials = new Set(
|
||||
settings.map((s) => s.mat?.name).filter(Boolean)
|
||||
).size;
|
||||
|
||||
const lensCounts = settings.reduce((acc: Record<string, number>, cur) => {
|
||||
const v = cur.lens?.name;
|
||||
if (!v) return acc;
|
||||
acc[v] = (acc[v] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const mostCommonLens =
|
||||
Object.entries(lensCounts).sort(
|
||||
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
|
||||
)[0]?.[0] || "—";
|
||||
|
||||
const srcCounts = settings.reduce((acc: Record<string, number>, cur) => {
|
||||
const v = cur.source?.model;
|
||||
if (!v) return acc;
|
||||
acc[v] = (acc[v] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const mostCommonSource =
|
||||
Object.entries(srcCounts).sort(
|
||||
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
|
||||
)[0]?.[0] || "—";
|
||||
|
||||
const recent = [...settings]
|
||||
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<style jsx global>{`
|
||||
mark {
|
||||
background: #ffde59;
|
||||
color: #242424;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header / Search */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h1 className="text-2xl font-bold mb-2">CO₂ Gantry Settings</h1>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by material, owner, uploader, model, lens…"
|
||||
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Explore curated CO₂ gantry settings. Click any row to see full
|
||||
details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>Total Settings: {total}</li>
|
||||
<li>Unique Materials: {uniqueMaterials}</li>
|
||||
<li>Most Common Lens: {mostCommonLens}</li>
|
||||
<li>Most Used Source: {mostCommonSource}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Recently Added */}
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Recently Added</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{recent.map((s) => (
|
||||
<li key={s.submission_id}>
|
||||
<Link
|
||||
href={detailHref(s.submission_id)}
|
||||
className="underline text-accent"
|
||||
>
|
||||
{s.setting_title || "Untitled"}
|
||||
</Link>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
by {ownerLabel(s.owner)}
|
||||
{s.uploader ? ` (uploader: ${s.uploader})` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div className="card bg-card text-card-foreground p-4 xl:col-span-3">
|
||||
<h2 className="text-lg font-semibold mb-2">Resources</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="/materials"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-accent"
|
||||
>
|
||||
Material Safety Guide
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://lasereverything.net/scripts/laspwrconvert.php"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-accent"
|
||||
>
|
||||
Laser Parameter Calculator
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://jptoe.com/downloads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-accent"
|
||||
>
|
||||
JPT Datasheets
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading settings...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted">No gantry settings found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left">Photo</th>
|
||||
<th className="px-2 py-2 text-left">Title</th>
|
||||
<th className="px-2 py-2 text-left">Owner</th>
|
||||
<th className="px-2 py-2 text-left">Uploader</th>
|
||||
<th className="px-2 py-2 text-left">Material</th>
|
||||
<th className="px-2 py-2 text-left">Coating</th>
|
||||
<th className="px-2 py-2 text-left">Source</th>
|
||||
<th className="px-2 py-2 text-left">Lens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.submission_id} className="border-t border-border">
|
||||
<td className="px-2 py-2">
|
||||
{s.photo?.id ? (
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${s.photo.id}`}
|
||||
alt={s.photo.title || "laser preview"}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-md"
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">
|
||||
<Link
|
||||
href={detailHref(s.submission_id)}
|
||||
className="text-accent underline"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.setting_title || "—"),
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(ownerLabel(s.owner)),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.uploader || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.mat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.mat_coat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.source?.model || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.lens?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
app/app/settings/fiber/[id]/fiber.tsx
Normal file
402
app/app/settings/fiber/[id]/fiber.tsx
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export default function FiberSettingDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [setting, setSetting] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// claim UI state
|
||||
const [claimBusy, setClaimBusy] = useState(false);
|
||||
const [claimMsg, setClaimMsg] = useState<string | null>(null);
|
||||
const [claimErr, setClaimErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const url =
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber/${id}` +
|
||||
`?fields=` +
|
||||
[
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"uploader",
|
||||
// Owner (M2O): prefer username
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
// Content & assets
|
||||
"setting_notes",
|
||||
"photo.filename_disk",
|
||||
"photo.title",
|
||||
"screen.filename_disk",
|
||||
"screen.title",
|
||||
// Relations / denorms
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"mat_color.name",
|
||||
"mat_opacity.opacity",
|
||||
"mat_thickness",
|
||||
"source.make",
|
||||
"source.model",
|
||||
"lens.field_size",
|
||||
"lens.focal_length",
|
||||
// laser_soft is a STRING field
|
||||
"laser_soft",
|
||||
"focus",
|
||||
"repeat_all",
|
||||
"fill_settings",
|
||||
"line_settings",
|
||||
"raster_settings",
|
||||
].join(",");
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to load");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setSetting(data.data))
|
||||
.catch(() => setSetting(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <p className="p-6">Loading setting...</p>;
|
||||
if (!setting) return <p className="p-6">Setting not found.</p>;
|
||||
|
||||
// Prefer owner's username per schema
|
||||
const ownerName = (row: any) => {
|
||||
const o = row?.owner;
|
||||
if (!o) return null;
|
||||
return o.username || null;
|
||||
};
|
||||
|
||||
const formatBoolean = (val: any) =>
|
||||
val ? "Enabled" : val === false ? "Disabled" : "—";
|
||||
|
||||
const renderRepeaterCard = (title: string, fields: any[], items: any[]) => {
|
||||
const filtered = (items || []).filter((item) =>
|
||||
Object.values(item).some((v) => v !== null && v !== "")
|
||||
);
|
||||
if (filtered.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
{filtered.map((item, i) => (
|
||||
<div key={i} className="border border-border rounded-lg p-4 bg-card">
|
||||
{fields.map(({ key, label, condition }: any) => {
|
||||
const value = item[key];
|
||||
if (condition && !condition(item)) return null;
|
||||
return (
|
||||
<p key={key} className="text-sm">
|
||||
<strong>{label}:</strong>{" "}
|
||||
{typeof value === "boolean"
|
||||
? formatBoolean(value)
|
||||
: value || "—"}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const openSearchInNewTab = (value: string) => {
|
||||
if (!value || typeof window === "undefined") return;
|
||||
const url = new URL("/settings/fiber", window.location.origin);
|
||||
url.searchParams.set("query", value);
|
||||
const a = document.createElement("a");
|
||||
a.href = url.toString();
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.click();
|
||||
};
|
||||
|
||||
const onClaim = async () => {
|
||||
setClaimBusy(true);
|
||||
setClaimErr(null);
|
||||
setClaimMsg(null);
|
||||
try {
|
||||
const r = await fetch("/api/claims", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target_collection: "settings_fiber",
|
||||
target_id: id,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
data?.error || data?.errors?.[0]?.message || "Failed to submit claim"
|
||||
);
|
||||
}
|
||||
setClaimMsg("Claim request submitted for review.");
|
||||
} catch (e: any) {
|
||||
setClaimErr(e?.message || "Failed to submit claim");
|
||||
} finally {
|
||||
setClaimBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const owner = ownerName(setting);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{/* Title / Meta / Ownership */}
|
||||
<div className="card bg-card p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-1">{setting.setting_title}</h1>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground mb-3">
|
||||
<p>
|
||||
<strong>Owner:</strong>{" "}
|
||||
{owner ? <span>{owner}</span> : <span>—</span>}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Uploader:</strong> {setting.uploader || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!owner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClaim}
|
||||
disabled={claimBusy}
|
||||
className="px-3 py-1.5 rounded bg-accent text-background text-sm disabled:opacity-60"
|
||||
>
|
||||
{claimBusy ? "Submitting…" : "Claim this setting"}
|
||||
</button>
|
||||
{claimMsg && (
|
||||
<span className="text-green-500 text-sm">{claimMsg}</span>
|
||||
)}
|
||||
{claimErr && (
|
||||
<span className="text-red-500 text-sm">{claimErr}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/settings/fiber"
|
||||
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
|
||||
>
|
||||
← Back to Fiber Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Result Photo + Material */}
|
||||
<div className="card bg-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="flex justify-center">
|
||||
{setting.photo?.filename_disk && (
|
||||
<a
|
||||
href={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
|
||||
alt={setting.photo?.title || "Laser preview"}
|
||||
width={250}
|
||||
height={250}
|
||||
className="rounded object-contain max-w-[250px] max-h-[250px]"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Material</h2>
|
||||
<p>
|
||||
<strong>Material:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.mat?.name)}
|
||||
>
|
||||
{setting.mat?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Coating:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.mat_coat?.name)}
|
||||
>
|
||||
{setting.mat_coat?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Color:</strong> {setting.mat_color?.name || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Opacity:</strong> {setting.mat_opacity?.opacity || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Thickness:</strong>{" "}
|
||||
{setting.mat_thickness ? `${setting.mat_thickness} mm` : "Not Applicable"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup */}
|
||||
<div className="card bg-card p-4">
|
||||
<h2 className="text-xl font-semibold mb-2">Setup</h2>
|
||||
{/* laser_soft is a string field */}
|
||||
<p>
|
||||
<strong>Software:</strong> {setting.laser_soft || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Repeat All (global):</strong> {setting.repeat_all ?? "—"}
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
<strong>Focus:</strong> {setting.focus ?? "—"} mm
|
||||
</p>
|
||||
<small>-Values Focus Closer | +Values Focus Further</small>
|
||||
</div>
|
||||
|
||||
{/* Laser (heading above specs; screen image same size as result) */}
|
||||
<div className="card bg-card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
{/* Screen image */}
|
||||
<div className="flex justify-center">
|
||||
{setting.screen?.filename_disk ? (
|
||||
<a
|
||||
href={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={setting.screen?.title || "Screenshot"}
|
||||
>
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
|
||||
alt={setting.screen?.title || "Screenshot"}
|
||||
width={250}
|
||||
height={250}
|
||||
className="rounded object-contain max-w-[250px] max-h-[250px]"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No screenshot</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Specs + heading */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Laser</h2>
|
||||
<p>
|
||||
<strong>Source Make:</strong> {setting.source?.make || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Source Model:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.source?.model)}
|
||||
>
|
||||
{setting.source?.model || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Lens:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.lens?.field_size)}
|
||||
>
|
||||
{setting.lens?.field_size || "—"}
|
||||
</span>{" "}
|
||||
mm | {setting.lens?.focal_length || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{setting.setting_notes && (
|
||||
<div className="prose dark:prose-invert mt-6">
|
||||
<h2>Notes</h2>
|
||||
<Markdown>{setting.setting_notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="my-6 border-muted" />
|
||||
|
||||
{/* Fill / Line / Raster */}
|
||||
{renderRepeaterCard(
|
||||
"Fill Settings",
|
||||
[
|
||||
{ key: "fill_name", label: "Fill Name" },
|
||||
{ key: "power", label: "Power (%)" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "frequency", label: "Frequency (kHz)" },
|
||||
{ key: "pulse", label: "Pulse Width (ns)" },
|
||||
{ key: "interval", label: "Interval (mm)" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "angle", label: "Angle (°)" },
|
||||
{ key: "auto", label: "Auto-Rotate" },
|
||||
{ key: "increment", label: "Increment (°)", condition: (e: any) => e.auto },
|
||||
{ key: "cross", label: "Crosshatch" },
|
||||
{ key: "flood", label: "Flood Fill" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.fill_settings
|
||||
)}
|
||||
|
||||
{renderRepeaterCard(
|
||||
"Line Settings",
|
||||
[
|
||||
{ key: "name", label: "Line Name" },
|
||||
{ key: "power", label: "Power (%)" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "frequency", label: "Frequency (kHz)" },
|
||||
{ key: "pulse", label: "Pulse Width (ns)" },
|
||||
{ key: "perf", label: "Perforation Mode" },
|
||||
{ key: "cut", label: "Cut (mm)", condition: (e: any) => e.perf },
|
||||
{ key: "skip", label: "Skip (mm)", condition: (e: any) => e.perf },
|
||||
{ key: "wobble", label: "Wobble Mode" },
|
||||
{ key: "step", label: "Step (mm)", condition: (e: any) => e.wobble },
|
||||
{ key: "size", label: "Size (mm)", condition: (e: any) => e.wobble },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.line_settings
|
||||
)}
|
||||
|
||||
{renderRepeaterCard(
|
||||
"Raster Settings",
|
||||
[
|
||||
{ key: "name", label: "Raster Name" },
|
||||
{ key: "power", label: "Power (%)" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "frequency", label: "Frequency (kHz)" },
|
||||
{ key: "pulse", label: "Pulse Width (ns)" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "dither", label: "Dither" },
|
||||
{
|
||||
key: "halftone_cell",
|
||||
label: "Cell Size (mm)",
|
||||
condition: (e: any) => e.dither === "halftone",
|
||||
},
|
||||
{
|
||||
key: "halftone_angle",
|
||||
label: "Halftone Angle",
|
||||
condition: (e: any) => e.dither === "halftone",
|
||||
},
|
||||
{ key: "inversion", label: "Image Inverted" },
|
||||
{ key: "interval", label: "Interval (mm)" },
|
||||
{ key: "dot", label: "Dot-width Adjustment (mm)" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "cross", label: "Crosshatch" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.raster_settings
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
app/app/settings/fiber/[id]/page.tsx
Normal file
2
app/app/settings/fiber/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/settings/fiber/[id]/page.tsx
|
||||
export { default } from "./fiber";
|
||||
4
app/app/settings/fiber/layout.tsx
Normal file
4
app/app/settings/fiber/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
315
app/app/settings/fiber/page.tsx
Normal file
315
app/app/settings/fiber/page.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function FiberSettingsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialQuery = searchParams.get("query") || "";
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const [settings, setSettings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// canonical detail href builder (no modal yet)
|
||||
const detailHref = (id: string | number) => `/settings/fiber/${id}`;
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
// Include owner fields now that settings have an M2O "owner"
|
||||
const url =
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_fiber` +
|
||||
`?fields=` +
|
||||
[
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"uploader",
|
||||
"owner.username",
|
||||
"owner.first_name",
|
||||
"owner.last_name",
|
||||
"owner.email",
|
||||
"photo.id",
|
||||
"photo.title",
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"source.model",
|
||||
"lens.field_size",
|
||||
].join(",") +
|
||||
`&limit=-1`;
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setSettings(data?.data || []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// ── Owner display helper: prefer username ────────────────────────────────────
|
||||
const ownerName = (row: any) => {
|
||||
const o = row?.owner || {};
|
||||
return o?.username || "—";
|
||||
};
|
||||
|
||||
// ── Highlight helper for search matches ─────────────────────────────────────
|
||||
const highlight = (text?: string) => {
|
||||
if (!debouncedQuery) return text || "";
|
||||
const regex = new RegExp(`(${debouncedQuery})`, "gi");
|
||||
return (text || "").replace(regex, "<mark>$1</mark>");
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return settings.filter((entry) => {
|
||||
const fieldsToSearch = [
|
||||
entry.setting_title,
|
||||
entry.uploader,
|
||||
ownerName(entry),
|
||||
entry.mat?.name,
|
||||
entry.mat_coat?.name,
|
||||
entry.source?.model,
|
||||
entry.lens?.field_size,
|
||||
];
|
||||
return fieldsToSearch
|
||||
.filter(Boolean)
|
||||
.some((field: string) => String(field).toLowerCase().includes(q));
|
||||
});
|
||||
}, [settings, debouncedQuery]);
|
||||
|
||||
const totalSettings = settings.length;
|
||||
const uniqueMaterials = new Set(
|
||||
settings.map((s) => s.mat?.name).filter(Boolean)
|
||||
).size;
|
||||
|
||||
const commonLens = settings.reduce((acc: Record<string, number>, cur) => {
|
||||
const lens = cur.lens?.field_size;
|
||||
if (!lens) return acc;
|
||||
acc[lens] = (acc[lens] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const mostCommonLens =
|
||||
Object.entries(commonLens).sort(
|
||||
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
|
||||
)[0]?.[0] || "—";
|
||||
|
||||
const sourceModels = settings.reduce((acc: Record<string, number>, cur) => {
|
||||
const model = cur.source?.model;
|
||||
if (!model) return acc;
|
||||
acc[model] = (acc[model] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const mostCommonSource =
|
||||
Object.entries(sourceModels).sort(
|
||||
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
|
||||
)[0]?.[0] || "—";
|
||||
|
||||
const recentSettings = [...settings]
|
||||
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<style jsx global>{`
|
||||
mark {
|
||||
background: #ffde59;
|
||||
color: #242424;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header + Search */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h1 className="text-2xl font-bold mb-2">Fiber Laser Settings</h1>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by material, owner, uploader, model, etc…"
|
||||
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View and explore detailed fiber laser settings with context.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">How to Use</h2>
|
||||
<p className="text-sm">
|
||||
Browse community fiber laser settings. Use the search to narrow
|
||||
results. Click any setting title to view its full configuration,
|
||||
notes, and photos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>Total Settings: {totalSettings}</li>
|
||||
<li>Unique Materials: {uniqueMaterials}</li>
|
||||
<li>Most Common Lens: {mostCommonLens}</li>
|
||||
<li>Most Used Source: {mostCommonSource}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Recently Added</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{recentSettings.map((s) => (
|
||||
<li key={s.submission_id}>
|
||||
<Link
|
||||
href={detailHref(s.submission_id)}
|
||||
className="underline text-accent"
|
||||
>
|
||||
{s.setting_title || "Untitled"}
|
||||
</Link>{" "}
|
||||
{/* keep uploader for now while claims/owners migrate */}
|
||||
<span className="text-muted-foreground">
|
||||
by {ownerName(s) !== "—" ? ownerName(s) : s.uploader || "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Resources</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="/materials"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-accent"
|
||||
>
|
||||
Material Safety Guide
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://lasereverything.net/scripts/laspwrconvert.php"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-accent"
|
||||
>
|
||||
Laser Parameter Calculator
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://jptoe.com/downloads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-accent"
|
||||
>
|
||||
JPT Datasheets
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading settings...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted">No fiber settings found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left">Photo</th>
|
||||
<th className="px-2 py-2 text-left">Title</th>
|
||||
<th className="px-2 py-2 text-left">Owner</th>
|
||||
<th className="px-2 py-2 text-left">Uploader</th>
|
||||
<th className="px-2 py-2 text-left">Material</th>
|
||||
<th className="px-2 py-2 text-left">Coating</th>
|
||||
<th className="px-2 py-2 text-left">Source</th>
|
||||
<th className="px-2 py-2 text-left">Lens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((setting) => (
|
||||
<tr key={setting.submission_id} className="border-t border-border">
|
||||
<td className="px-2 py-2">
|
||||
{setting.photo?.id ? (
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${setting.photo.id}`}
|
||||
alt={setting.photo.title || "laser preview"}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-md"
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 whitespace-nowrap">
|
||||
<Link
|
||||
href={detailHref(setting.submission_id)}
|
||||
className="text-accent underline"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.setting_title || "—"),
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{ __html: highlight(ownerName(setting)) }}
|
||||
/>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.uploader || "—"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.mat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.mat_coat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.source?.model || "—"),
|
||||
}}
|
||||
/>
|
||||
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(setting.lens?.field_size || "—"),
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
app/app/settings/uv/[id]/page.tsx
Normal file
2
app/app/settings/uv/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// app/settings/uv/[id]/page.tsx
|
||||
export { default } from "./uv";
|
||||
365
app/app/settings/uv/[id]/uv.tsx
Normal file
365
app/app/settings/uv/[id]/uv.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export default function UVSettingDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [setting, setSetting] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// claim UI state
|
||||
const [claimBusy, setClaimBusy] = useState(false);
|
||||
const [claimMsg, setClaimMsg] = useState<string | null>(null);
|
||||
const [claimErr, setClaimErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const url =
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_uv/${id}` +
|
||||
`?fields=` +
|
||||
[
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"uploader",
|
||||
// ✅ Owner (M2O) — use username (string)
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
// content & assets
|
||||
"setting_notes",
|
||||
"photo.filename_disk",
|
||||
"photo.title",
|
||||
"screen.filename_disk",
|
||||
"screen.title",
|
||||
// relations / denorms
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"mat_color.name",
|
||||
"mat_opacity.opacity",
|
||||
"mat_thickness",
|
||||
"source.model",
|
||||
"lens.field_size",
|
||||
"lens.focal_length",
|
||||
// misc
|
||||
"focus",
|
||||
"fill_settings",
|
||||
"line_settings",
|
||||
"raster_settings",
|
||||
].join(",");
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to load");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setSetting(data.data))
|
||||
.catch(() => setSetting(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <p className="p-6">Loading setting...</p>;
|
||||
if (!setting) return <p className="p-6">Setting not found.</p>;
|
||||
|
||||
// ✅ Prefer owner's username per schema
|
||||
const ownerName = (row: any) => row?.owner?.username ?? null;
|
||||
|
||||
const formatBoolean = (val: any) =>
|
||||
val ? "Enabled" : val === false ? "Disabled" : "—";
|
||||
|
||||
const renderRepeaterCard = (title: string, fields: any[], items: any[]) => {
|
||||
const filtered = (items || []).filter((item) =>
|
||||
Object.values(item).some((v) => v !== null && v !== "")
|
||||
);
|
||||
if (filtered.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
{filtered.map((item, i) => (
|
||||
<div key={i} className="border border-border rounded-lg p-4 bg-card">
|
||||
{fields.map(({ key, label, condition }: any) => {
|
||||
const value = item[key];
|
||||
if (condition && !condition(item)) return null;
|
||||
return (
|
||||
<p key={key} className="text-sm">
|
||||
<strong>{label}:</strong>{" "}
|
||||
{typeof value === "boolean" ? formatBoolean(value) : value ?? "—"}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ✅ Point searches/back link to /settings/uv
|
||||
const openSearchInNewTab = (value: string) => {
|
||||
if (!value || typeof window === "undefined") return;
|
||||
const url = new URL("/settings/uv", window.location.origin);
|
||||
url.searchParams.set("query", value);
|
||||
const a = document.createElement("a");
|
||||
a.href = url.toString();
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.click();
|
||||
};
|
||||
|
||||
const onClaim = async () => {
|
||||
setClaimBusy(true);
|
||||
setClaimErr(null);
|
||||
setClaimMsg(null);
|
||||
try {
|
||||
const r = await fetch("/api/claims", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
target_collection: "settings_uv",
|
||||
target_id: id,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
data?.error || data?.errors?.[0]?.message || "Failed to submit claim"
|
||||
);
|
||||
}
|
||||
setClaimMsg("Claim request submitted for review.");
|
||||
} catch (e: any) {
|
||||
setClaimErr(e?.message || "Failed to submit claim");
|
||||
} finally {
|
||||
setClaimBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const owner = ownerName(setting);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{/* Title / Meta / Ownership */}
|
||||
<div className="card bg-card p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-1">{setting.setting_title}</h1>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground mb-3">
|
||||
<p>
|
||||
<strong>Owner:</strong>{" "}
|
||||
{owner ? <span>{owner}</span> : <span>—</span>}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Uploader:</strong> {setting.uploader || "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!owner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClaim}
|
||||
disabled={claimBusy}
|
||||
className="px-3 py-1.5 rounded bg-accent text-background text-sm disabled:opacity-60"
|
||||
>
|
||||
{claimBusy ? "Submitting…" : "Claim this setting"}
|
||||
</button>
|
||||
{claimMsg && (
|
||||
<span className="text-green-500 text-sm">{claimMsg}</span>
|
||||
)}
|
||||
{claimErr && (
|
||||
<span className="text-red-500 text-sm">{claimErr}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/settings/uv"
|
||||
className="inline-block mt-2 px-4 py-2 bg-accent text-background rounded-md text-sm self-start"
|
||||
>
|
||||
← Back to UV Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Result Photo + Material */}
|
||||
<div className="card bg-card p-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="flex justify-center">
|
||||
{setting.photo?.filename_disk && (
|
||||
<a
|
||||
href={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${setting.photo.filename_disk}`}
|
||||
alt={setting.photo?.title || "Preview"}
|
||||
width={250}
|
||||
height={250}
|
||||
className="rounded object-contain max-w-[250px] max-h-[250px]"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Material</h2>
|
||||
<p>
|
||||
<strong>Material:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.mat?.name)}
|
||||
>
|
||||
{setting.mat?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Coating:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.mat_coat?.name)}
|
||||
>
|
||||
{setting.mat_coat?.name || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Color:</strong> {setting.mat_color?.name || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Opacity:</strong> {setting.mat_opacity?.opacity || "—"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Thickness:</strong>{" "}
|
||||
{setting.mat_thickness ? `${setting.mat_thickness} mm` : "Not Applicable"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup */}
|
||||
<div className="card bg-card p-4">
|
||||
<h2 className="text-xl font-semibold mb-2">Setup</h2>
|
||||
<p>
|
||||
<strong>Focus:</strong> {setting.focus ?? "—"} mm
|
||||
</p>
|
||||
<small>-Values Focus Closer | +Values Focus Further</small>
|
||||
</div>
|
||||
|
||||
{/* Laser (screenshot on left, specs on right; heading above specs) */}
|
||||
<div className="card bg-card p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="flex justify-center">
|
||||
{setting.screen?.filename_disk ? (
|
||||
<a
|
||||
href={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={setting.screen?.title || "Screenshot"}
|
||||
>
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${setting.screen.filename_disk}`}
|
||||
alt={setting.screen?.title || "Screenshot"}
|
||||
width={250}
|
||||
height={250}
|
||||
className="rounded object-contain max-w-[250px] max-h-[250px]"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No screenshot</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-2">Laser</h2>
|
||||
<p>
|
||||
<strong>Source Model:</strong>{" "}
|
||||
<span
|
||||
className="cursor-pointer underline hover:text-accent"
|
||||
onClick={() => openSearchInNewTab(setting.source?.model)}
|
||||
>
|
||||
{setting.source?.model || "—"}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Lens:</strong> {setting.lens?.field_size || "—"} mm |{" "}
|
||||
{setting.lens?.focal_length || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{setting.setting_notes && (
|
||||
<div className="prose dark:prose-invert mt-6">
|
||||
<h2>Notes</h2>
|
||||
<Markdown>{setting.setting_notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="my-6 border-muted" />
|
||||
|
||||
{/* Repeaters (UV-specific fields kept intact) */}
|
||||
{renderRepeaterCard(
|
||||
"Fill Settings",
|
||||
[
|
||||
{ key: "name", label: "Fill Name" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "frequency", label: "Frequency (kHz)" },
|
||||
{ key: "pulse", label: "Pulse Width (ns)" },
|
||||
{ key: "interval", label: "Interval (mm)" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "angle", label: "Angle (°)" },
|
||||
{ key: "auto", label: "Auto-Rotate" },
|
||||
{ key: "increment", label: "Increment (°)", condition: (e: any) => e.auto },
|
||||
{ key: "cross", label: "Crosshatch" },
|
||||
{ key: "flood", label: "Flood Fill" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.fill_settings
|
||||
)}
|
||||
|
||||
{renderRepeaterCard(
|
||||
"Line Settings",
|
||||
[
|
||||
{ key: "name", label: "Line Name" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "frequency", label: "Frequency (kHz)" },
|
||||
{ key: "pulse", label: "Pulse Width (ns)" },
|
||||
{ key: "perf", label: "Perforation Mode" },
|
||||
{ key: "cut", label: "Cut Override" },
|
||||
{ key: "skip", label: "Skip Pass" },
|
||||
{ key: "wobble", label: "Wobble Enabled" },
|
||||
{ key: "step", label: "Wobble Step" },
|
||||
{ key: "size", label: "Wobble Size" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.line_settings
|
||||
)}
|
||||
|
||||
{renderRepeaterCard(
|
||||
"Raster Settings",
|
||||
[
|
||||
{ key: "name", label: "Raster Name" },
|
||||
{ key: "speed", label: "Speed (mm/s)" },
|
||||
{ key: "frequency", label: "Frequency (kHz)" },
|
||||
{ key: "pulse", label: "Pulse Width (ns)" },
|
||||
{ key: "type", label: "Type" },
|
||||
{ key: "dither", label: "Dither" },
|
||||
{ key: "halftone_cell", label: "Halftone Cell" },
|
||||
{ key: "halftone_angle", label: "Halftone Angle" },
|
||||
{ key: "inversion", label: "Invert Colors" },
|
||||
{ key: "interval", label: "Interval (mm)" },
|
||||
{ key: "dot", label: "Dot Size" },
|
||||
{ key: "pass", label: "Passes" },
|
||||
{ key: "cross", label: "Crosshatch" },
|
||||
{ key: "air", label: "Air Assist" },
|
||||
],
|
||||
setting.raster_settings
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
app/app/settings/uv/layout.tsx
Normal file
4
app/app/settings/uv/layout.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Suspense } from "react";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
278
app/app/settings/uv/page.tsx
Normal file
278
app/app/settings/uv/page.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
type Owner = {
|
||||
id?: string | number;
|
||||
username?: string | null;
|
||||
};
|
||||
|
||||
export default function UVSettingsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialQuery = searchParams.get("query") || "";
|
||||
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
|
||||
const [settings, setSettings] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const detailHref = (id: string | number) => `/settings/uv/${id}`;
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const url =
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/items/settings_uv` +
|
||||
`?fields=` +
|
||||
[
|
||||
"submission_id",
|
||||
"setting_title",
|
||||
"uploader",
|
||||
// owner (M2O) – ask for username explicitly
|
||||
"owner.id",
|
||||
"owner.username",
|
||||
// assets / denorms
|
||||
"photo.id",
|
||||
"photo.title",
|
||||
"mat.name",
|
||||
"mat_coat.name",
|
||||
"source.model",
|
||||
"lens.field_size",
|
||||
].join(",") +
|
||||
`&limit=-1`;
|
||||
|
||||
fetch(url, { cache: "no-store" })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => setSettings(data?.data || []))
|
||||
.catch((e) => {
|
||||
console.error("UV settings fetch failed:", e);
|
||||
setSettings([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const ownerLabel = (o?: Owner) => (o?.username ?? "—");
|
||||
|
||||
const highlight = (text?: string) => {
|
||||
if (!debouncedQuery) return text || "";
|
||||
const regex = new RegExp(`(${debouncedQuery})`, "gi");
|
||||
return (text || "").replace(regex, "<mark>$1</mark>");
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return settings.filter((entry) => {
|
||||
const fieldsToSearch = [
|
||||
entry.setting_title,
|
||||
ownerLabel(entry.owner),
|
||||
entry.uploader,
|
||||
entry.mat?.name,
|
||||
entry.mat_coat?.name,
|
||||
entry.source?.model,
|
||||
entry.lens?.field_size,
|
||||
];
|
||||
return fieldsToSearch
|
||||
.filter(Boolean)
|
||||
.some((field: string) => String(field).toLowerCase().includes(q));
|
||||
});
|
||||
}, [settings, debouncedQuery]);
|
||||
|
||||
const total = settings.length;
|
||||
const uniqueMaterials = new Set(
|
||||
settings.map((s) => s.mat?.name).filter(Boolean)
|
||||
).size;
|
||||
|
||||
const lensCounts = settings.reduce((acc: Record<string, number>, cur) => {
|
||||
const v = cur.lens?.field_size;
|
||||
if (!v) return acc;
|
||||
acc[v] = (acc[v] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const mostCommonLens =
|
||||
Object.entries(lensCounts).sort(
|
||||
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
|
||||
)[0]?.[0] || "—";
|
||||
|
||||
const srcCounts = settings.reduce((acc: Record<string, number>, cur) => {
|
||||
const v = cur.source?.model;
|
||||
if (!v) return acc;
|
||||
acc[v] = (acc[v] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const mostCommonSource =
|
||||
Object.entries(srcCounts).sort(
|
||||
(a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)
|
||||
)[0]?.[0] || "—";
|
||||
|
||||
const recent = [...settings]
|
||||
.sort((a, b) => Number(b.submission_id) - Number(a.submission_id))
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<style jsx global>{`
|
||||
mark {
|
||||
background: #ffde59;
|
||||
color: #242424;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Header / Search */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mb-6">
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h1 className="text-2xl font-bold mb-2">UV Laser Settings</h1>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by material, owner, uploader, model, lens…"
|
||||
className="w-full mb-4 dark:bg-background border border-border rounded-md p-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
View and explore detailed UV laser settings with context.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* How to use */}
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">How to Use</h2>
|
||||
<p className="text-sm">
|
||||
Browse community UV settings. Use search to narrow results. Click a
|
||||
row to view full configuration, notes, and photos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="card bg-card text-card-foreground p-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Stats Summary</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li>Total Settings: {total}</li>
|
||||
<li>Unique Materials: {uniqueMaterials}</li>
|
||||
<li>Most Common Lens: {mostCommonLens}</li>
|
||||
<li>Most Used Source: {mostCommonSource}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Recently Added */}
|
||||
<div className="card bg-card text-card-foreground p-4 xl:col-span-3">
|
||||
<h2 className="text-lg font-semibold mb-2">Recently Added</h2>
|
||||
<ul className="text-sm space-y-1">
|
||||
{recent.map((s) => (
|
||||
<li key={s.submission_id}>
|
||||
<Link
|
||||
href={detailHref(s.submission_id)}
|
||||
className="underline text-accent"
|
||||
>
|
||||
{s.setting_title || "Untitled"}
|
||||
</Link>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
by {ownerLabel(s.owner)}
|
||||
{s.uploader ? ` (uploader: ${s.uploader})` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<p className="text-muted">Loading settings...</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-muted">No UV settings found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left">Photo</th>
|
||||
<th className="px-2 py-2 text-left">Title</th>
|
||||
<th className="px-2 py-2 text-left">Owner</th>
|
||||
<th className="px-2 py-2 text-left">Uploader</th>
|
||||
<th className="px-2 py-2 text-left">Material</th>
|
||||
<th className="px-2 py-2 text-left">Coating</th>
|
||||
<th className="px-2 py-2 text-left">Source</th>
|
||||
<th className="px-2 py-2 text-left">Lens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.submission_id} className="border-t border-border">
|
||||
<td className="px-2 py-2">
|
||||
{s.photo?.id ? (
|
||||
<Image
|
||||
src={`https://forms.lasereverything.net/assets/${s.photo.id}`}
|
||||
alt={s.photo.title || "laser preview"}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-md"
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-2 whitespace-nowrap">
|
||||
<Link
|
||||
href={detailHref(s.submission_id)}
|
||||
className="text-accent underline"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.setting_title || "—"),
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(ownerLabel(s.owner)),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.uploader || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.mat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.mat_coat?.name || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.source?.model || "—"),
|
||||
}}
|
||||
/>
|
||||
<td
|
||||
className="px-2 py-2 whitespace-nowrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlight(s.lens?.field_size || "—"),
|
||||
}}
|
||||
/>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/app/styles/globals.css
Normal file
66
app/app/styles/globals.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 13%;
|
||||
--foreground: 0 0% 85%;
|
||||
--card: 0 0% 15%;
|
||||
--card-foreground: 0 0% 90%;
|
||||
--primary: 0 0% 20%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--accent: 35 100% 60%;
|
||||
--muted: 0 0% 40%;
|
||||
--muted-foreground: 0 0% 60%;
|
||||
--border: 0 0% 30%;
|
||||
--input: 0 0% 20%;
|
||||
--ring: 0 0% 50%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-mono text-sm leading-relaxed;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-accent underline hover:brightness-125;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
@apply bg-card text-card-foreground border border-border rounded-md px-3 py-2;
|
||||
}
|
||||
|
||||
table {
|
||||
@apply border-separate border-spacing-y-1 text-left w-full;
|
||||
}
|
||||
|
||||
th, td {
|
||||
@apply px-4 py-2;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply text-card-foreground font-semibold;
|
||||
}
|
||||
|
||||
tr {
|
||||
@apply border-b border-border;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-card text-card-foreground p-4 rounded-md shadow;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary text-primary-foreground px-4 py-2 rounded hover:brightness-110;
|
||||
}
|
||||
}
|
||||
|
||||
20
app/app/submit/settings/page.tsx
Normal file
20
app/app/submit/settings/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Suspense } from "react";
|
||||
import SettingsSubmit from "@/components/forms/SettingsSubmit";
|
||||
|
||||
export const dynamic = "force-dynamic"; // keeps this page from being statically prerendered
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="px-4 py-8 max-w-5xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-2">Community Laser Settings Submission</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Contribute tested settings. Submissions are reviewed before publishing.
|
||||
</p>
|
||||
|
||||
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading form…</div>}>
|
||||
<SettingsSubmit />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
72
app/app/submit/settings/success/page.tsx
Normal file
72
app/app/submit/settings/success/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// app/submit/settings/success/page.tsx
|
||||
import Link from "next/link";
|
||||
|
||||
type Target = "settings_fiber" | "settings_co2gan" | "settings_co2gal" | "settings_uv";
|
||||
|
||||
const TARGET_TO_LIST: Record<Target, string> = {
|
||||
settings_fiber: "/fiber-settings",
|
||||
settings_co2gan: "/co2-gantry-settings",
|
||||
settings_co2gal: "/co2-galvo-settings",
|
||||
settings_uv: "/uv-settings",
|
||||
};
|
||||
|
||||
const TARGET_LABEL: Record<Target, string> = {
|
||||
settings_fiber: "Fiber",
|
||||
settings_co2gan: "CO₂ Gantry",
|
||||
settings_co2gal: "CO₂ Galvo",
|
||||
settings_uv: "UV",
|
||||
};
|
||||
|
||||
export default async function SuccessPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const sp = searchParams ?? {};
|
||||
|
||||
const rawTarget = sp?.target;
|
||||
const rawId = sp?.id;
|
||||
|
||||
const valid: Target[] = ["settings_fiber", "settings_co2gan", "settings_co2gal", "settings_uv"];
|
||||
const t: Target = valid.includes(rawTarget as Target)
|
||||
? (rawTarget as Target)
|
||||
: "settings_fiber";
|
||||
|
||||
const id = Array.isArray(rawId) ? rawId[0] : rawId || "";
|
||||
const listHref = TARGET_TO_LIST[t];
|
||||
const label = TARGET_LABEL[t];
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-10 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-green-600 text-white flex items-center justify-center">✓</div>
|
||||
<h1 className="text-xl font-semibold">Submission received</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-base">
|
||||
Your {label} submission has been received.
|
||||
{id ? (
|
||||
<>
|
||||
{" "}
|
||||
Reference ID: <span className="font-mono">{id}</span>.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link className="px-3 py-2 rounded border" href={listHref}>
|
||||
View {label} database
|
||||
</Link>
|
||||
<Link
|
||||
className="px-3 py-2 rounded border"
|
||||
href={`/submit/settings?target=${encodeURIComponent(t)}`}
|
||||
>
|
||||
Submit another
|
||||
</Link>
|
||||
<Link className="px-3 py-2 rounded border" href="/">
|
||||
Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
app/components.json
Normal file
21
app/components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
47
app/components/PortalTabs.tsx
Normal file
47
app/components/PortalTabs.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// components/PortalTabs.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tabs = [
|
||||
{ href: "/portal", label: "Home" },
|
||||
{ href: "/portal/rigs", label: "Rigs" },
|
||||
{ href: "/portal/laser-settings", label: "Laser Settings" },
|
||||
{ href: "/portal/laser-sources", label: "Laser Sources" },
|
||||
{ href: "/portal/materials", label: "Materials" },
|
||||
{ href: "/portal/projects", label: "Projects" },
|
||||
{ href: "/portal/buying-guide", label: "Buying Guide" }, // ⬅️ NEW
|
||||
{ href: "/portal/utilities", label: "Utilities" },
|
||||
{ href: "/portal/account", label: "Account" },
|
||||
];
|
||||
|
||||
export default function PortalTabs() {
|
||||
const pathname = usePathname() || "/portal";
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap items-center gap-1 rounded-md border bg-background p-1">
|
||||
{tabs.map((t) => {
|
||||
const active =
|
||||
pathname === t.href ||
|
||||
(t.href !== "/portal" && pathname.startsWith(t.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition",
|
||||
active ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="ml-auto px-3 py-1.5 text-xs opacity-60">MakerDash</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
53
app/components/SignOutButton.tsx
Normal file
53
app/components/SignOutButton.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
/** Where to send users after logout */
|
||||
redirectTo?: string; // default /auth/sign-in
|
||||
};
|
||||
|
||||
export default function SignOutButton({
|
||||
className,
|
||||
redirectTo = "/auth/sign-in",
|
||||
}: Props) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
async function onClick() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "include", // make sure cookies are cleared
|
||||
});
|
||||
|
||||
// include ?next= so they can land back here after re-auth if desired
|
||||
const next = pathname ? `?next=${encodeURIComponent(pathname)}` : "?next=/portal";
|
||||
router.push(redirectTo + next);
|
||||
router.refresh();
|
||||
} catch {
|
||||
router.push(redirectTo);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "Signing out…" : "Sign out"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
86
app/components/account/AvatarUploader.tsx
Normal file
86
app/components/account/AvatarUploader.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// components/account/AvatarUploader.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export default function AvatarUploader({
|
||||
avatarId,
|
||||
onUpdated,
|
||||
}: {
|
||||
avatarId?: string | null;
|
||||
onUpdated?: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const API_BASE = useMemo(
|
||||
() => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""),
|
||||
[]
|
||||
);
|
||||
const currentUrl = avatarId ? `${API_BASE}/assets/${avatarId}` : null;
|
||||
|
||||
const onUpload = async () => {
|
||||
setMsg(null);
|
||||
if (!file) {
|
||||
setMsg("Choose a file first.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("file", file, file.name);
|
||||
|
||||
const r = await fetch("/api/account/avatar", { method: "POST", body: fd });
|
||||
if (r.status === 401) {
|
||||
location.assign(`/auth/sign-in?reauth=1&next=${encodeURIComponent("/portal/account")}`);
|
||||
return;
|
||||
}
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
setMsg(j?.error || "Upload failed");
|
||||
return;
|
||||
}
|
||||
setMsg("Avatar updated.");
|
||||
setFile(null);
|
||||
onUpdated?.();
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Upload failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<h3 className="font-semibold">Avatar</h3>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
|
||||
{currentUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={currentUrl} alt="Avatar" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs opacity-60">No Avatar</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="text-sm"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
disabled={busy || !file}
|
||||
className="rounded-md bg-black text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{busy ? "Uploading…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className="text-sm opacity-80">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
app/components/account/ConfirmIdentity.tsx
Normal file
63
app/components/account/ConfirmIdentity.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// components/account/ConfirmIdentity.tsx
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function ConfirmIdentity({
|
||||
defaultIdentifier,
|
||||
onSuccess,
|
||||
}: {
|
||||
defaultIdentifier: string; // prefill with username or email you show on the page
|
||||
onSuccess: () => void; // called after re-auth succeeds
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [identifier, setIdentifier] = useState(defaultIdentifier);
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch("/api/auth/reconfirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j?.error || "Failed");
|
||||
setOpen(false);
|
||||
setPassword("");
|
||||
onSuccess(); // now do the sensitive call
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Re-auth failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* wherever you need the step-up, render a button that opens this */}
|
||||
<button className="btn" onClick={() => setOpen(true)}>Confirm it’s you</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/50 grid place-items-center">
|
||||
<div className="bg-card border rounded-lg p-4 w-full max-w-sm">
|
||||
<h3 className="font-semibold mb-2">Confirm it’s you</h3>
|
||||
<label className="block text-sm mb-1">Email or Username</label>
|
||||
<input className="input w-full mb-2" value={identifier} onChange={e=>setIdentifier(e.target.value)} />
|
||||
<label className="block text-sm mb-1">Password</label>
|
||||
<input className="input w-full mb-3" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
||||
{err && <div className="text-red-600 text-sm mb-2">{err}</div>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="btn" onClick={()=>setOpen(false)} disabled={busy}>Cancel</button>
|
||||
<button className="btn-primary" onClick={submit} disabled={busy}>{busy ? "Checking…" : "Continue"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
app/components/account/ConnectKofi.tsx
Normal file
137
app/components/account/ConnectKofi.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// components/account/ConnectKofi.tsx
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export default function ConnectKofi({
|
||||
email,
|
||||
userId,
|
||||
}: {
|
||||
email?: string | null;
|
||||
userId?: string | null;
|
||||
}) {
|
||||
const [value, setValue] = useState(email || "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const canSubmit = useMemo(
|
||||
() => !!value && /\S+@\S+\.\S+/.test(value),
|
||||
[value]
|
||||
);
|
||||
|
||||
const startClaim = useCallback(async () => {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/support/kofi/claim/start", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// if your auth middleware expects anything, set it here; otherwise cookies suffice
|
||||
"x-user-id": userId ?? "",
|
||||
"x-user-email": email ?? "",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: value }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({} as any));
|
||||
if (!res.ok) {
|
||||
// Show specific errors where helpful
|
||||
const detail = j?.detail || j?.error || res.statusText;
|
||||
throw new Error(
|
||||
j?.error === "not_found"
|
||||
? "We don’t have any Ko-fi records for that email yet. If you’re sure it’s correct, try again after your next Ko-fi payment or after we run the backfill."
|
||||
: String(detail || "Failed to start verification")
|
||||
);
|
||||
}
|
||||
if (j?.alreadyLinked) {
|
||||
setMsg("This Ko-fi email is already linked to your account.");
|
||||
} else {
|
||||
setMsg(
|
||||
"Verification email sent! Check your inbox and click the link to finish linking Ko-fi."
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Something went wrong.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [value, userId, email]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/support/kofi/unlink", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-user-id": userId ?? "",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
throw new Error(t || "Unlink failed");
|
||||
}
|
||||
setMsg("Ko-fi has been unlinked from your account.");
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Unlink failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-4">
|
||||
<h3 className="mb-2 text-base font-semibold">Link Ko-fi</h3>
|
||||
<p className="mb-3 text-sm opacity-80">
|
||||
Enter the email you use on Ko-fi. We’ll send a one-time verification link to confirm it’s you.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded border px-3 py-2 text-sm"
|
||||
placeholder="you@kofi-email.com"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startClaim}
|
||||
disabled={!canSubmit || busy}
|
||||
className="inline-flex items-center justify-center rounded bg-black px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black"
|
||||
>
|
||||
{busy ? "Sending…" : "Send Verify Link"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={unlink}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center rounded border px-4 py-2 text-sm"
|
||||
title="Remove the Ko-fi link from your account"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="mt-3 rounded-md border border-emerald-300/50 bg-emerald-50 p-2 text-sm text-emerald-900">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
<div className="mt-3 rounded-md border border-red-300/50 bg-red-50 p-2 text-sm text-red-900">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-xs opacity-70">
|
||||
Tip: after you verify, badges update automatically. If you don’t see a badge yet, it’ll appear the next time a Ko-fi payment webhook arrives (or after backfill).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue