Initial Commit
5
.firebaserc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "pickle-3651a"
|
||||
}
|
||||
}
|
116
.gitignore
vendored
Normal file
@ -0,0 +1,116 @@
|
||||
# Logs
|
||||
src/data
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.DS_Store
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Webpack
|
||||
.webpack/
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
.specstory
|
||||
.specstory/
|
||||
|
||||
data/pickleglass.db
|
||||
pickleglass_web/backend/__pycache__/
|
||||
pickleglass_web/venv/
|
||||
|
||||
# Node / JS
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
package-lock.json
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
|
||||
# Build output
|
||||
out/
|
||||
dist/
|
||||
build/
|
3
.npmrc
Normal file
@ -0,0 +1,3 @@
|
||||
better-sqlite3:ignore-scripts=true
|
||||
electron-deeplink:ignore-scripts=true
|
||||
sharp:ignore-scripts=true
|
2
.prettierignore
Normal file
@ -0,0 +1,2 @@
|
||||
src/assets
|
||||
node_modules
|
10
.prettierrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 150,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"search.useIgnoreFiles": true
|
||||
}
|
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
102
README.md
Normal file
@ -0,0 +1,102 @@
|
||||
<p align="center">
|
||||
<a href="https://pickle.com/glass">
|
||||
<img src="./public/assets/banner.gif" alt="Logo">
|
||||
</a>
|
||||
|
||||
<h1 align="center">Glass by Pickle: Digital Mind Extension 🧠</h1>
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/UCZH5B5Hpd"><img src="./public/assets/button_dc.png" width="80" alt="Pickle Discord"></a> <a href="https://pickle.com"><img src="./public/assets/button_we.png" width="105" alt="Pickle Website"></a> <a href="https://x.com/intent/user?screen_name=leinadpark"><img src="./public/assets/button_xe.png" width="109" alt="Follow Daniel"></a>
|
||||
</p>
|
||||
|
||||
> This project is a fork of [CheatingDaddy](https://github.com/sohzm/cheating-daddy) with modifications and enhancements. Thanks to [Soham](https://x.com/soham_btw) and all the open-source contributors who made this possible!
|
||||
|
||||
🤖 **Fast, light & open-source**—Glass lives on your desktop, sees what you see, listens in real time, understands your context, and turns every moment into structured knowledge.
|
||||
|
||||
💬 **Proactive in meetings**—it surfaces action items, summaries, and answers the instant you need them.
|
||||
|
||||
🫥️ **Truly invisible**—never shows up in screen recordings, screenshots, or your dock; no always-on capture or hidden sharing.
|
||||
|
||||
To have fun building with us, join our [Discord](https://discord.gg/UCZH5B5Hpd)!
|
||||
|
||||
## Instant Launch
|
||||
|
||||
⚡️ Skip the setup—launch instantly with our ready-to-run macOS app. [[Download Here]](https://www.dropbox.com/scl/fi/znid09apxiwtwvxer6oc9/Glass_latest.dmg?rlkey=gwvvyb3bizkl25frhs4k1zwds&st=37q31b4w&dl=1)
|
||||
|
||||
## Quick Start (Local Build)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
First download & install [Python](https://www.python.org/downloads/) and [Node](https://nodejs.org/en/download).
|
||||
If you are using Windows, you need to also install [Build Tools for Visual Studio](https://visualstudio.microsoft.com/downloads/)
|
||||
|
||||
Ensure you're using Node.js version 20.x.x to avoid build errors with native dependencies.
|
||||
|
||||
```bash
|
||||
# Check your Node.js version
|
||||
node --version
|
||||
|
||||
# If you need to install Node.js 20.x.x, we recommend using nvm:
|
||||
# curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
# nvm install 20
|
||||
# nvm use 20
|
||||
```
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
|
||||
### Ask: get answers based on all your previous screen actions & audio
|
||||
|
||||
<img width="100%" alt="booking-screen" src="./public/assets/00.gif">
|
||||
|
||||
### Meetings: real-time meeting notes, live summaries, session records
|
||||
|
||||
<img width="100%" alt="booking-screen" src="./public/assets/01.gif">
|
||||
|
||||
### Use your own OpenAI API key, or sign up to use ours (free)
|
||||
|
||||
<img width="100%" alt="booking-screen" src="./public/assets/02.gif">
|
||||
|
||||
You can visit [here](https://platform.openai.com/api-keys) to get your OpenAI API Key.
|
||||
|
||||
### Liquid Glass Design (coming soon)
|
||||
|
||||
<img width="100%" alt="booking-screen" src="./public/assets/03.gif">
|
||||
|
||||
<p>
|
||||
for a more detailed guide, please refer to this <a href="https://www.youtube.com/watch?v=qHg3_4bU1Dw">video.</a>
|
||||
<i style="color:gray; font-weight:300;">
|
||||
we don't waste money on fancy vids; we just code.
|
||||
</i>
|
||||
</p>
|
||||
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
`Ctrl/Cmd + \` : show and hide main window
|
||||
|
||||
`Ctrl/Cmd + Enter` : ask AI using all your previous screen and audio
|
||||
|
||||
`Ctrl/Cmd + Arrows` : move main window position
|
||||
|
||||
## Contributing
|
||||
|
||||
We love contributions! Feel free to open issues for bugs or feature requests.
|
||||
|
||||
## About Pickle
|
||||
|
||||
**Our mission is to build a living digital clone for everyone.** Glass is part of Step 1—a trusted pipeline that transforms your daily data into a scalable clone. Visit [pickle.com](https://pickle.com) to learn more.
|
||||
|
||||
## Star History
|
||||
|
||||
<img src="./public/assets/star-history-202574.png">
|
||||
|
57
build.js
Normal file
@ -0,0 +1,57 @@
|
||||
const esbuild = require('esbuild');
|
||||
const path = require('path');
|
||||
|
||||
const baseConfig = {
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
format: 'esm',
|
||||
loader: { '.js': 'jsx' },
|
||||
sourcemap: true,
|
||||
external: ['electron'],
|
||||
define: {
|
||||
'process.env.NODE_ENV': `"${process.env.NODE_ENV || 'development'}"`,
|
||||
},
|
||||
};
|
||||
|
||||
const entryPoints = [
|
||||
{ in: 'src/app/HeaderController.js', out: 'public/build/header' },
|
||||
{ in: 'src/app/PickleGlassApp.js', out: 'public/build/content' },
|
||||
];
|
||||
|
||||
async function build() {
|
||||
try {
|
||||
console.log('Building renderer process code...');
|
||||
await Promise.all(entryPoints.map(point => esbuild.build({
|
||||
...baseConfig,
|
||||
entryPoints: [point.in],
|
||||
outfile: `${point.out}.js`,
|
||||
})));
|
||||
console.log('✅ Renderer builds successful!');
|
||||
} catch (e) {
|
||||
console.error('Renderer build failed:', e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function watch() {
|
||||
try {
|
||||
const contexts = await Promise.all(entryPoints.map(point => esbuild.context({
|
||||
...baseConfig,
|
||||
entryPoints: [point.in],
|
||||
outfile: `${point.out}.js`,
|
||||
})));
|
||||
|
||||
console.log('Watching for changes...');
|
||||
await Promise.all(contexts.map(context => context.watch()));
|
||||
|
||||
} catch (e) {
|
||||
console.error('Watch mode failed:', e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv.includes('--watch')) {
|
||||
watch();
|
||||
} else {
|
||||
build();
|
||||
}
|
36
electron-builder.yml
Normal file
@ -0,0 +1,36 @@
|
||||
# electron-builder.yml
|
||||
|
||||
# The unique application ID
|
||||
appId: com.pickle.glass
|
||||
|
||||
# The user-facing application name
|
||||
productName: Glass
|
||||
|
||||
# Publish configuration for GitHub releases
|
||||
publish:
|
||||
provider: github
|
||||
owner: pickle-com
|
||||
repo: glass
|
||||
releaseType: draft
|
||||
|
||||
# List of files to be included in the app package
|
||||
files:
|
||||
- src/**/*
|
||||
- package.json
|
||||
- pickleglass_web/backend_node/**/*
|
||||
- '!**/node_modules/electron/**'
|
||||
- public/build/**/*
|
||||
|
||||
# Additional resources to be copied into the app's resources directory
|
||||
extraResources:
|
||||
- from: src/assets/SystemAudioDump
|
||||
to: SystemAudioDump
|
||||
- from: pickleglass_web/out
|
||||
to: out
|
||||
|
||||
# macOS specific configuration
|
||||
mac:
|
||||
# The application category type
|
||||
category: public.app-category.utilities
|
||||
# Path to the .icns icon file
|
||||
icon: src/assets/logo.icns
|
28
entitlements.plist
Normal file
@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||
<array>
|
||||
<string>com.deeplink.pickleglass.MachPortRendezvousServer.*</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
36
firebase.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"functions": [
|
||||
{
|
||||
"source": "functions",
|
||||
"codebase": "pickle-glass",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git",
|
||||
"firebase-debug.log",
|
||||
"firebase-debug.*.log",
|
||||
"*.local"
|
||||
],
|
||||
"predeploy": [
|
||||
"npm --prefix \"$RESOURCE_DIR\" run lint"
|
||||
]
|
||||
}
|
||||
],
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"hosting": {
|
||||
"public": "pickleglass_web/out",
|
||||
"ignore": [
|
||||
"firebase.json",
|
||||
"**/.*",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
4
firestore.indexes.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"indexes": [],
|
||||
"fieldOverrides": []
|
||||
}
|
86
forge.config.js
Normal file
@ -0,0 +1,86 @@
|
||||
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
|
||||
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
||||
const { notarizeApp } = require('./notarize');
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
asar: {
|
||||
unpack:
|
||||
'**/*.node,**/*.dylib,' +
|
||||
'**/node_modules/{sharp,@img}/**/*'
|
||||
},
|
||||
extraResource: ['./src/assets/SystemAudioDump', './pickleglass_web/out'],
|
||||
name: 'Glass',
|
||||
icon: 'src/assets/logo',
|
||||
appBundleId: 'com.pickle.glass',
|
||||
protocols: [
|
||||
{
|
||||
name: 'PickleGlass Protocol',
|
||||
schemes: ['pickleglass']
|
||||
}
|
||||
],
|
||||
asarUnpack: [
|
||||
"**/*.node",
|
||||
"**/*.dylib",
|
||||
"node_modules/@img/sharp-darwin-arm64/**",
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64/**"
|
||||
],
|
||||
osxSign: {
|
||||
identity: process.env.APPLE_SIGNING_IDENTITY,
|
||||
'hardened-runtime': true,
|
||||
entitlements: 'entitlements.plist',
|
||||
'entitlements-inherit': 'entitlements.plist',
|
||||
},
|
||||
osxNotarize: {
|
||||
tool: 'notarytool',
|
||||
appleId: process.env.APPLE_ID,
|
||||
appleIdPassword: process.env.APPLE_ID_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID
|
||||
}
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
config: {
|
||||
name: 'pickle-glass',
|
||||
productName: 'Glass',
|
||||
shortcutName: 'Glass',
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-dmg',
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterSign: async (context, forgeConfig, platform, arch, appPath) => {
|
||||
await notarizeApp(context, forgeConfig, platform, arch, appPath);
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: false,
|
||||
}),
|
||||
],
|
||||
};
|
28
functions/.eslintrc.js
Normal file
@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
es6: true,
|
||||
node: true,
|
||||
},
|
||||
parserOptions: {
|
||||
"ecmaVersion": 2018,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"google",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-globals": ["error", "name", "length"],
|
||||
"prefer-arrow-callback": "error",
|
||||
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/*.spec.*"],
|
||||
env: {
|
||||
mocha: true,
|
||||
},
|
||||
rules: {},
|
||||
},
|
||||
],
|
||||
globals: {},
|
||||
};
|
2
functions/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
*.local
|
90
functions/index.js
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Import function triggers from their respective submodules:
|
||||
*
|
||||
* const {onCall} = require("firebase-functions/v2/https");
|
||||
* const {onDocumentWritten} = require("firebase-functions/v2/firestore");
|
||||
*
|
||||
* See a full list of supported triggers at https://firebase.google.com/docs/functions
|
||||
*/
|
||||
|
||||
const {onRequest} = require("firebase-functions/v2/https");
|
||||
const logger = require("firebase-functions/logger");
|
||||
const admin = require("firebase-admin");
|
||||
const cors = require("cors")({origin: true});
|
||||
|
||||
admin.initializeApp();
|
||||
|
||||
// Create and deploy your first functions
|
||||
// https://firebase.google.com/docs/functions/get-started
|
||||
|
||||
// exports.helloWorld = onRequest((request, response) => {
|
||||
// logger.info("Hello logs!", {structuredData: true});
|
||||
// response.send("Hello from Firebase!");
|
||||
// });
|
||||
|
||||
/**
|
||||
* @name pickleGlassAuthCallback
|
||||
* @description
|
||||
* Validate Firebase ID token and return custom token.
|
||||
* On success, return success response with user information.
|
||||
* On failure, return error message.
|
||||
*
|
||||
* @param {object} request - HTTPS request object. Contains { token: "..." } in body.
|
||||
* @param {object} response - HTTPS response object.
|
||||
*/
|
||||
const authCallbackHandler = (request, response) => {
|
||||
cors(request, response, async () => {
|
||||
try {
|
||||
logger.info("pickleGlassAuthCallback function triggered", {
|
||||
body: request.body,
|
||||
});
|
||||
|
||||
if (request.method !== "POST") {
|
||||
response.status(405).send("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
if (!request.body || !request.body.token) {
|
||||
logger.error("Token is missing from the request body");
|
||||
response.status(400).send({
|
||||
success: false,
|
||||
error: "ID token is required.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const idToken = request.body.token;
|
||||
logger.info("Received token:", idToken.substring(0, 20) + "...");
|
||||
|
||||
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||
const uid = decodedToken.uid;
|
||||
|
||||
logger.info("Successfully verified token for UID:", uid);
|
||||
|
||||
const customToken = await admin.auth().createCustomToken(uid);
|
||||
|
||||
response.status(200).send({
|
||||
success: true,
|
||||
message: "Authentication successful.",
|
||||
user: {
|
||||
uid: decodedToken.uid,
|
||||
email: decodedToken.email,
|
||||
name: decodedToken.name,
|
||||
picture: decodedToken.picture,
|
||||
},
|
||||
customToken,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Authentication failed:", error);
|
||||
response.status(401).send({
|
||||
success: false,
|
||||
error: "Invalid token or authentication failed.",
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.pickleGlassAuthCallback = onRequest(
|
||||
{region: "us-west1"},
|
||||
authCallbackHandler,
|
||||
);
|
27
functions/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"description": "Cloud Functions for Firebase",
|
||||
"scripts": {
|
||||
"lint": "eslint . --fix",
|
||||
"serve": "firebase emulators:start --only functions",
|
||||
"shell": "firebase functions:shell",
|
||||
"start": "npm run shell",
|
||||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18"
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"firebase-admin": "^12.7.0",
|
||||
"firebase-functions": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"firebase-functions-test": "^3.1.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
27
notarize.js
Normal file
@ -0,0 +1,27 @@
|
||||
const { notarize } = require('@electron/notarize');
|
||||
|
||||
exports.notarizeApp = async function (context) {
|
||||
if (context.electronPlatformName !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(' notarizing a macOS build!');
|
||||
|
||||
const { appOutDir } = context;
|
||||
const appName = context.packager.appInfo.productFilename;
|
||||
const appPath = `${appOutDir}/${appName}.app`;
|
||||
|
||||
if (!process.env.APPLE_ID || !process.env.APPLE_ID_PASSWORD || !process.env.APPLE_TEAM_ID) {
|
||||
throw new Error('APPLE_ID, APPLE_ID_PASSWORD, and APPLE_TEAM_ID environment variables are required for notarization.');
|
||||
}
|
||||
|
||||
await notarize({
|
||||
appBundleId: 'com.pickle.glass',
|
||||
appPath: appPath,
|
||||
appleId: process.env.APPLE_ID,
|
||||
appleIdPassword: process.env.APPLE_ID_PASSWORD,
|
||||
teamId: process.env.APPLE_TEAM_ID,
|
||||
});
|
||||
|
||||
console.log(`Successfully notarized ${appName}`);
|
||||
};
|
69
package.json
Normal file
@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "pickle-glass",
|
||||
"productName": "Glass",
|
||||
"version": "0.1.1",
|
||||
"description": "Cl*ely for Free",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"setup": "npm install && cd pickleglass_web && npm install && npm run build && cd .. && npm start",
|
||||
"start": "npm run build:renderer && electron-forge start",
|
||||
"package": "npm run build:renderer && electron-forge package",
|
||||
"make": "npm run build:renderer && electron-forge make",
|
||||
"build": "npm run build:renderer && electron-builder --config electron-builder.yml --publish never",
|
||||
"publish": "npm run build:renderer && electron-builder --config electron-builder.yml --publish always",
|
||||
"lint": "eslint --ext .ts,.tsx,.js .",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:renderer": "node build.js",
|
||||
"watch:renderer": "node build.js --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"glass",
|
||||
"pickle glass",
|
||||
"ai assistant",
|
||||
"real-time",
|
||||
"live summary",
|
||||
"contextual ai"
|
||||
],
|
||||
"author": {
|
||||
"name": "Pickle Team"
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.0",
|
||||
"electron-deeplink": "^1.0.10",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
"express": "^4.18.2",
|
||||
"firebase": "^11.10.0",
|
||||
"firebase-admin": "^13.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"openai": "^4.70.0",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"sharp": "^0.34.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"validator": "^13.11.0",
|
||||
"wait-on": "^8.0.3",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.8.1",
|
||||
"@electron-forge/maker-deb": "^7.8.1",
|
||||
"@electron-forge/maker-dmg": "^7.8.1",
|
||||
"@electron-forge/maker-rpm": "^7.8.1",
|
||||
"@electron-forge/maker-squirrel": "^7.8.1",
|
||||
"@electron-forge/maker-zip": "^7.8.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.8.1",
|
||||
"@electron-forge/plugin-fuses": "^7.8.1",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"electron": "^30.5.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-reloader": "^1.2.3",
|
||||
"esbuild": "^0.25.5"
|
||||
}
|
||||
}
|
17
pickleglass_web/Dockerfile.backend
Normal file
@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
15
pickleglass_web/Dockerfile.frontend
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
149
pickleglass_web/app/activity/details/page.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRedirectIfNotAuth } from '@/utils/auth'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
UserProfile,
|
||||
SessionDetails,
|
||||
Transcript,
|
||||
AiMessage,
|
||||
getSessionDetails,
|
||||
} from '@/utils/api'
|
||||
|
||||
type ConversationItem = (Transcript & { type: 'transcript' }) | (AiMessage & { type: 'ai_message' });
|
||||
|
||||
const Section = ({ title, children }: { title: string, children: React.ReactNode }) => (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">{title}</h2>
|
||||
<div className="text-gray-700 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function SessionDetailsContent() {
|
||||
const userInfo = useRedirectIfNotAuth() as UserProfile | null;
|
||||
const [sessionDetails, setSessionDetails] = useState<SessionDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
|
||||
useEffect(() => {
|
||||
if (userInfo && sessionId) {
|
||||
const fetchDetails = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const details = await getSessionDetails(sessionId as string);
|
||||
setSessionDetails(details);
|
||||
} catch (error) {
|
||||
console.error('Failed to load session details:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchDetails();
|
||||
}
|
||||
}, [userInfo, sessionId]);
|
||||
|
||||
if (!userInfo || isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FDFCF9] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading session details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessionDetails) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FDFCF9] flex items-center justify-center">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12 text-center">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-8">Session Not Found</h2>
|
||||
<p className="text-gray-600">The requested session could not be found.</p>
|
||||
<Link href="/activity" className="mt-4 inline-block text-blue-600 hover:text-blue-800">
|
||||
← Back to Activity
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const combinedConversation = [
|
||||
...sessionDetails.transcripts.map(t => ({ ...t, type: 'transcript' as const, created_at: t.start_at })),
|
||||
...sessionDetails.ai_messages.map(m => ({ ...m, type: 'ai_message' as const, created_at: m.sent_at }))
|
||||
].sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
||||
|
||||
const audioTranscripts = sessionDetails.transcripts.filter(t => t.speaker !== 'Me');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#FDFCF9] text-gray-800">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<Link href="/activity" className="text-sm text-gray-500 hover:text-gray-700 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-8 rounded-xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{sessionDetails.session.title || `Conversation on ${new Date(sessionDetails.session.started_at * 1000).toLocaleDateString()}`}
|
||||
</h1>
|
||||
<div className="flex items-center text-sm text-gray-500 space-x-4">
|
||||
<span>{new Date(sessionDetails.session.started_at * 1000).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
|
||||
<span>{new Date(sessionDetails.session.started_at * 1000).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionDetails.summary && (
|
||||
<Section title="Summary">
|
||||
<p className="italic">"{sessionDetails.summary.tldr}"</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Notes">
|
||||
{combinedConversation.map((item) => (
|
||||
<p key={item.id}>
|
||||
<span className="font-semibold">{(item.type === 'transcript' && item.speaker === 'Me') || (item.type === 'ai_message' && item.role === 'user') ? 'You: ' : 'AI: '}</span>
|
||||
{item.type === 'transcript' ? item.text : item.content}
|
||||
</p>
|
||||
))}
|
||||
{combinedConversation.length === 0 && <p>No notes recorded for this session.</p>}
|
||||
</Section>
|
||||
|
||||
<Section title="Audio transcript content">
|
||||
{audioTranscripts.length > 0 ? (
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{audioTranscripts.map(t => <li key={t.id}>{t.text}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No audio transcript available for this session.</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionDetailsPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-[#FDFCF9] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<SessionDetailsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
97
pickleglass_web/app/activity/page.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRedirectIfNotAuth } from '@/utils/auth'
|
||||
import {
|
||||
UserProfile,
|
||||
Session,
|
||||
getSessions,
|
||||
} from '@/utils/api'
|
||||
|
||||
export default function ActivityPage() {
|
||||
const userInfo = useRedirectIfNotAuth() as UserProfile | null;
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const fetchedSessions = await getSessions();
|
||||
setSessions(fetchedSessions);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch conversations:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions()
|
||||
}, [])
|
||||
|
||||
if (!userInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Good morning'
|
||||
if (hour < 18) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto px-8 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-2xl text-gray-600">
|
||||
{getGreeting()}, {userInfo.display_name}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-8 text-center">
|
||||
Your Past Activity
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading conversations...</p>
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<Link href={`/activity/details?sessionId=${session.id}`} key={session.id} className="block bg-white rounded-lg p-6 shadow-sm border border-gray-200 hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-lg font-medium text-gray-900">{session.title || `Conversation - ${new Date(session.started_at * 1000).toLocaleDateString()}`}</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(session.started_at * 1000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Conversation
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center bg-white rounded-lg p-12">
|
||||
<p className="text-gray-500 mb-4">
|
||||
No conversations yet. Start a conversation in the desktop app to see your activity here.
|
||||
</p>
|
||||
<div className="text-sm text-gray-400">
|
||||
💡 Tip: Use the desktop app to have AI-powered conversations that will appear here automatically.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
102
pickleglass_web/app/download/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { Download, Smartphone, Monitor, Tablet } from 'lucide-react'
|
||||
import { useRedirectIfNotAuth } from '@/utils/auth'
|
||||
|
||||
export default function DownloadPage() {
|
||||
const userInfo = useRedirectIfNotAuth()
|
||||
|
||||
if (!userInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Download pickleglass</h1>
|
||||
<p className="text-lg text-gray-600 mb-12">
|
||||
Use pickleglass on various platforms
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-8 hover:shadow-lg transition-shadow">
|
||||
<Monitor className="h-16 w-16 text-blue-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Desktop</h3>
|
||||
<p className="text-gray-600 mb-6">Windows, macOS, Linux</p>
|
||||
<button className="w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<Download className="h-5 w-5 inline mr-2" />
|
||||
Download Desktop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-8 hover:shadow-lg transition-shadow">
|
||||
<Smartphone className="h-16 w-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Mobile</h3>
|
||||
<p className="text-gray-600 mb-6">iOS, Android</p>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full bg-gray-900 text-white py-3 px-6 rounded-lg hover:bg-gray-800 transition-colors">
|
||||
App Store
|
||||
</button>
|
||||
<button className="w-full bg-green-600 text-white py-3 px-6 rounded-lg hover:bg-green-700 transition-colors">
|
||||
Google Play
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-8 hover:shadow-lg transition-shadow">
|
||||
<Tablet className="h-16 w-16 text-purple-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Tablet</h3>
|
||||
<p className="text-gray-600 mb-6">iPad, Android Tablet</p>
|
||||
<button className="w-full bg-purple-600 text-white py-3 px-6 rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<Download className="h-5 w-5 inline mr-2" />
|
||||
Download Tablet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 p-6 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">System Requirements</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-left">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Windows</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Windows 10 or later</li>
|
||||
<li>• 4GB RAM</li>
|
||||
<li>• 100MB Storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">macOS</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• macOS 11.0 or later</li>
|
||||
<li>• 4GB RAM</li>
|
||||
<li>• 100MB Storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-2">Mobile</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• iOS 14.0 or later</li>
|
||||
<li>• Android 8.0 or later</li>
|
||||
<li>• 50MB Storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-gray-600">
|
||||
Having issues? Check out our <a href="/help" className="text-blue-600 hover:text-blue-700">Help Center</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
34
pickleglass_web/app/globals.css
Normal file
@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
110
pickleglass_web/app/help/page.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { HelpCircle, Book, MessageCircle, Mail } from 'lucide-react'
|
||||
import { useRedirectIfNotAuth } from '@/utils/auth'
|
||||
|
||||
export default function HelpPage() {
|
||||
const userInfo = useRedirectIfNotAuth()
|
||||
|
||||
if (!userInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">Help Center</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Book className="h-6 w-6 text-blue-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Getting Started</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">
|
||||
New to pickleglass? Learn about basic features and setup methods.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li>• Setting up personalized contexts</li>
|
||||
<li>• Selecting presets and creating custom contexts</li>
|
||||
<li>• Checking activity records</li>
|
||||
<li>• Changing settings</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<HelpCircle className="h-6 w-6 text-green-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Frequently Asked Questions</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Check out frequently asked questions and answers from other users.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<details className="text-sm">
|
||||
<summary className="font-medium text-gray-700 cursor-pointer">
|
||||
How do I change the context?
|
||||
</summary>
|
||||
<p className="text-gray-600 mt-2 pl-4">
|
||||
On the Personalize page, select a preset or enter a custom context, then click the Save button.
|
||||
</p>
|
||||
</details>
|
||||
<details className="text-sm">
|
||||
<summary className="font-medium text-gray-700 cursor-pointer">
|
||||
Where can I check my activity history?
|
||||
</summary>
|
||||
<p className="text-gray-600 mt-2 pl-4">
|
||||
You can check your past activity records on the My Activity page.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<MessageCircle className="h-6 w-6 text-purple-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Community</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Connect with other users and share tips.
|
||||
</p>
|
||||
<button className="text-blue-600 hover:text-blue-700 text-sm font-medium">
|
||||
Join Community →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Mail className="h-6 w-6 text-red-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Contact Us</h2>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Couldn't find a solution? Contact us directly.
|
||||
</p>
|
||||
<button className="text-blue-600 hover:text-blue-700 text-sm font-medium">
|
||||
Contact via Email →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-6 bg-blue-50 rounded-lg">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">💡 Tip</h3>
|
||||
<p className="text-gray-700">
|
||||
Each context is optimized for different situations.
|
||||
Choose the appropriate preset for your work environment,
|
||||
or create your own custom context!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
26
pickleglass_web/app/layout.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import './globals.css'
|
||||
import { Inter } from 'next/font/google'
|
||||
import ClientLayout from '@/components/ClientLayout'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata = {
|
||||
title: 'pickleglass - AI Assistant',
|
||||
description: 'Personalized AI Assistant for various contexts',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<ClientLayout>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
132
pickleglass_web/app/login/page.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
|
||||
import { auth } from '@/utils/firebase'
|
||||
import { Chrome } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isElectronMode, setIsElectronMode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const mode = urlParams.get('mode')
|
||||
setIsElectronMode(mode === 'electron')
|
||||
}, [])
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
const provider = new GoogleAuthProvider()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await signInWithPopup(auth, provider)
|
||||
const user = result.user
|
||||
|
||||
if (user) {
|
||||
console.log('✅ Google login successful:', user.uid)
|
||||
|
||||
if (isElectronMode) {
|
||||
try {
|
||||
const idToken = await user.getIdToken()
|
||||
|
||||
const deepLinkUrl = `pickleglass://auth-success?` + new URLSearchParams({
|
||||
uid: user.uid,
|
||||
email: user.email || '',
|
||||
displayName: user.displayName || '',
|
||||
token: idToken
|
||||
}).toString()
|
||||
|
||||
console.log('🔗 Return to electron app via deep link:', deepLinkUrl)
|
||||
|
||||
window.location.href = deepLinkUrl
|
||||
|
||||
setTimeout(() => {
|
||||
alert('Login completed. Please return to Pickle Glass app.')
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Deep link processing failed:', error)
|
||||
alert('Login was successful but failed to return to app. Please check the app.')
|
||||
}
|
||||
}
|
||||
else if (typeof window !== 'undefined' && window.require) {
|
||||
try {
|
||||
const { ipcRenderer } = window.require('electron')
|
||||
const idToken = await user.getIdToken()
|
||||
|
||||
ipcRenderer.send('firebase-auth-success', {
|
||||
uid: user.uid,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
idToken
|
||||
})
|
||||
|
||||
console.log('📡 Auth info sent to electron successfully')
|
||||
} catch (error) {
|
||||
console.error('❌ Electron communication failed:', error)
|
||||
}
|
||||
}
|
||||
else {
|
||||
router.push('/settings')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ Google login failed:', error)
|
||||
|
||||
if (error.code !== 'auth/popup-closed-by-user') {
|
||||
alert('An error occurred during login. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Welcome to Pickle Glass</h1>
|
||||
<p className="text-gray-600 mt-2">Sign in with your Google account to sync your data across all devices.</p>
|
||||
{isElectronMode ? (
|
||||
<p className="text-sm text-blue-600 mt-1 font-medium">🔗 Login requested from Electron app</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 mt-1">Local mode will run if you don't sign in.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md border border-gray-200">
|
||||
<button
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-center gap-3 py-3 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Chrome className="h-5 w-5" />
|
||||
<span>{isLoading ? 'Signing in...' : 'Sign in with Google'}</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isElectronMode) {
|
||||
window.location.href = 'pickleglass://auth-success?uid=default_user&email=contact@pickle.com&displayName=Default%20User'
|
||||
} else {
|
||||
router.push('/settings')
|
||||
}
|
||||
}}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Continue in local mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
By signing in, you agree to our Terms of Service and Privacy Policy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
21
pickleglass_web/app/page.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.push('/personalize')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
172
pickleglass_web/app/personalize/page.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { getPresets, updatePreset, PromptPreset } from '@/utils/api'
|
||||
|
||||
export default function PersonalizePage() {
|
||||
const [allPresets, setAllPresets] = useState<PromptPreset[]>([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState<PromptPreset | null>(null);
|
||||
const [showPresets, setShowPresets] = useState(true);
|
||||
const [editorContent, setEditorContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const presetsData = await getPresets();
|
||||
setAllPresets(presetsData);
|
||||
|
||||
if (presetsData.length > 0) {
|
||||
const firstUserPreset = presetsData.find(p => p.is_default === 0) || presetsData[0];
|
||||
setSelectedPreset(firstUserPreset);
|
||||
setEditorContent(firstUserPreset.prompt);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch presets:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handlePresetClick = (preset: PromptPreset) => {
|
||||
if (isDirty && !window.confirm("You have unsaved changes. Are you sure you want to switch?")) {
|
||||
return;
|
||||
}
|
||||
setSelectedPreset(preset);
|
||||
setEditorContent(preset.prompt);
|
||||
setIsDirty(false);
|
||||
};
|
||||
|
||||
const handleEditorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditorContent(e.target.value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedPreset || saving || !isDirty) return;
|
||||
|
||||
if (selectedPreset.is_default === 1) {
|
||||
alert("Default presets cannot be modified.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await updatePreset(selectedPreset.id, {
|
||||
title: selectedPreset.title,
|
||||
prompt: editorContent
|
||||
});
|
||||
|
||||
setAllPresets(prev =>
|
||||
prev.map(p =>
|
||||
p.id === selectedPreset.id
|
||||
? { ...p, prompt: editorContent }
|
||||
: p
|
||||
)
|
||||
);
|
||||
setIsDirty(false);
|
||||
console.log('Save completed!');
|
||||
} catch (error) {
|
||||
console.error("Save failed:", error);
|
||||
alert("Failed to save preset. See console for details.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="bg-white border-b border-gray-100">
|
||||
<div className="px-8 pt-8 pb-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-2">Presets</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Personalize</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isDirty || selectedPreset?.is_default === 1}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||
!isDirty && !saving
|
||||
? 'bg-gray-500 text-white cursor-default'
|
||||
: saving
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-gray-600 text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{!isDirty && !saving ? 'Saved' : saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`transition-colors duration-300 ${showPresets ? 'bg-gray-50' : 'bg-white'}`}>
|
||||
<div className="px-8 py-6">
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => setShowPresets(!showPresets)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-800 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${showPresets ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
{showPresets ? 'Hide Presets' : 'Show Presets'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPresets && (
|
||||
<div className="grid grid-cols-5 gap-4 mb-6">
|
||||
{allPresets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className={`
|
||||
p-4 rounded-lg cursor-pointer transition-all duration-200 bg-white
|
||||
h-48 flex flex-col shadow-sm hover:shadow-md
|
||||
${selectedPreset?.id === preset.id
|
||||
? 'border-2 border-blue-500 shadow-md'
|
||||
: 'border border-gray-200 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<h3 className="font-semibold text-gray-900 mb-3 text-center text-sm">
|
||||
{preset.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 leading-relaxed flex-1 overflow-hidden">
|
||||
{preset.prompt.substring(0, 100) + (preset.prompt.length > 100 ? '...' : '')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white">
|
||||
<div className="h-full px-8 py-6">
|
||||
<textarea
|
||||
value={editorContent}
|
||||
onChange={handleEditorChange}
|
||||
className="w-full h-full text-sm text-gray-900 border-0 resize-none focus:outline-none bg-transparent font-mono leading-relaxed"
|
||||
placeholder="Select a preset or type directly..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
59
pickleglass_web/app/settings/billing/page.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useRedirectIfNotAuth } from '@/utils/auth'
|
||||
|
||||
export default function BillingPage() {
|
||||
const userInfo = useRedirectIfNotAuth()
|
||||
|
||||
if (!userInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile', name: 'Personal profile', href: '/settings' },
|
||||
{ id: 'privacy', name: 'Data & privacy', href: '/settings/privacy' },
|
||||
{ id: 'billing', name: 'Billing', href: '/settings/billing' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-stone-50 min-h-screen">
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<p className="text-xs text-gray-500 mb-1">Settings</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Personal settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<nav className="flex space-x-10">
|
||||
{tabs.map((tab) => (
|
||||
<a
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={`pb-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
tab.id === 'billing'
|
||||
? 'border-gray-900 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<h2 className="text-8xl font-black bg-gradient-to-r from-black to-gray-500 bg-clip-text text-transparent">
|
||||
Cl*ely For Free
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
506
pickleglass_web/app/settings/page.tsx
Normal file
@ -0,0 +1,506 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Check, ExternalLink, Cloud, HardDrive } from 'lucide-react'
|
||||
import { useAuth } from '@/utils/auth'
|
||||
import {
|
||||
UserProfile,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
checkApiKeyStatus,
|
||||
saveApiKey,
|
||||
deleteAccount,
|
||||
logout
|
||||
} from '@/utils/api'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ipcRenderer?: any;
|
||||
}
|
||||
}
|
||||
|
||||
type Tab = 'profile' | 'privacy' | 'billing'
|
||||
type BillingCycle = 'monthly' | 'annually'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user: userInfo, isLoading, mode } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<Tab>('profile')
|
||||
const [billingCycle, setBillingCycle] = useState<BillingCycle>('monthly')
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||
const [hasApiKey, setHasApiKey] = useState(false)
|
||||
const [apiKeyInput, setApiKeyInput] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [displayNameInput, setDisplayNameInput] = useState('')
|
||||
const router = useRouter()
|
||||
|
||||
const fetchApiKeyStatus = async () => {
|
||||
try {
|
||||
const apiKeyStatus = await checkApiKeyStatus()
|
||||
setHasApiKey(apiKeyStatus.hasApiKey)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch API key status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!userInfo) return
|
||||
|
||||
const fetchProfileData = async () => {
|
||||
try {
|
||||
const userProfile = await getUserProfile()
|
||||
setProfile(userProfile)
|
||||
setDisplayNameInput(userProfile.display_name)
|
||||
await fetchApiKeyStatus();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch profile data:", error)
|
||||
}
|
||||
}
|
||||
fetchProfileData()
|
||||
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.on('api-key-updated', () => {
|
||||
console.log('Received api-key-updated event from main process.');
|
||||
fetchApiKeyStatus();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.removeAllListeners('api-key-updated');
|
||||
}
|
||||
}
|
||||
}, [userInfo])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
router.push('/login')
|
||||
return null
|
||||
}
|
||||
|
||||
const isFirebaseMode = mode === 'firebase'
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as Tab, name: 'Personal Profile', href: '/settings' },
|
||||
{ id: 'privacy' as Tab, name: 'Data & Privacy', href: '/settings/privacy' },
|
||||
{ id: 'billing' as Tab, name: 'Billing', href: '/settings/billing' },
|
||||
]
|
||||
|
||||
const handleSaveApiKey = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await saveApiKey(apiKeyInput)
|
||||
setHasApiKey(true)
|
||||
setApiKeyInput('')
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.invoke('save-api-key', apiKeyInput);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save API key:", error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateDisplayName = async () => {
|
||||
if (!profile || displayNameInput === profile.display_name) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateUserProfile({ displayName: displayNameInput });
|
||||
setProfile(prev => prev ? { ...prev, display_name: displayNameInput } : null);
|
||||
} catch (error) {
|
||||
console.error("Failed to update display name:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmMessage = isFirebaseMode
|
||||
? "Are you sure you want to delete your account? This action cannot be undone and all data stored in Firebase will be deleted."
|
||||
: "Are you sure you want to delete your account? This action cannot be undone and all data will be deleted."
|
||||
|
||||
if (window.confirm(confirmMessage)) {
|
||||
try {
|
||||
await deleteAccount()
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
console.error("Failed to delete account:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const renderBillingContent = () => (
|
||||
<div className="space-y-8">
|
||||
<div className={`p-4 rounded-lg border ${isFirebaseMode ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{isFirebaseMode ? (
|
||||
<Cloud className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<HardDrive className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
<h3 className={`font-semibold ${isFirebaseMode ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
{isFirebaseMode ? 'Firebase Hosting Mode' : 'Local Execution Mode'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className={`text-sm ${isFirebaseMode ? 'text-blue-700' : 'text-gray-700'}`}>
|
||||
{isFirebaseMode
|
||||
? 'All data is safely stored and synchronized in Firebase Cloud.'
|
||||
: 'Data is stored in local database and you can use personal API keys.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
billingCycle === 'monthly'
|
||||
? 'bg-gray-200 text-gray-900'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingCycle('annually')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
billingCycle === 'annually'
|
||||
? 'bg-gray-200 text-gray-900'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Annually
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Free</h3>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
$0<span className="text-lg font-normal text-gray-600">/month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Experience how Pickle Glass works with unlimited responses.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Daily unlimited responses</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Unlimited access to free models</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Unlimited text output</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Screen viewing, audio listening</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Custom system prompts</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Community support only</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button className="w-full py-2 px-4 bg-gray-200 text-gray-700 rounded-md font-medium">
|
||||
Current Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 opacity-60">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Pro</h3>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
$25<span className="text-lg font-normal text-gray-600">/month</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Use latest models, get full response output, and work with custom prompts.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Unlimited pro responses</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Unlimited access to latest models</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Full access to conversation dashboard</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">Priority support</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-gray-700">All features from free plan</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button className="w-full py-2 px-4 bg-cyan-400 text-white rounded-md font-medium">
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 text-white rounded-lg p-6 opacity-60">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold mb-2">Enterprise</h3>
|
||||
<div className="text-xl font-semibold">Custom</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-6">
|
||||
Specially crafted for teams that need complete customization.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 mb-8">
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Custom integrations</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-sm text-gray-300">User provisioning & role-based access</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Advanced post-call analytics</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Single sign-on</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Advanced security features</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Centralized billing</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Usage analytics & reporting dashboard</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button className="w-full py-2 px-4 bg-gray-600 text-white rounded-md font-medium">
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Check className="h-6 w-6 text-green-600" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-900">All features are currently free!</h4>
|
||||
<p className="text-green-700 text-sm">
|
||||
{isFirebaseMode
|
||||
? 'Enjoy all Pickle Glass features for free in Firebase hosting mode. Pro and Enterprise plans will be released soon with additional premium features.'
|
||||
: 'Enjoy all Pickle Glass features for free in local mode. You can use personal API keys or continue using the free system.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'billing':
|
||||
return renderBillingContent()
|
||||
case 'profile':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className={`p-4 rounded-lg border ${isFirebaseMode ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isFirebaseMode ? (
|
||||
<Cloud className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<HardDrive className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`font-semibold ${isFirebaseMode ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
{isFirebaseMode ? 'Firebase Hosting Mode' : 'Local Execution Mode'}
|
||||
</h3>
|
||||
<p className={`text-sm ${isFirebaseMode ? 'text-blue-700' : 'text-gray-700'}`}>
|
||||
{isFirebaseMode
|
||||
? `Logged in with Google account (${userInfo.email})`
|
||||
: 'Running as local user'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isFirebaseMode && (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:text-blue-700 underline"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">Display Name</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">Enter your full name or a display name you're comfortable using.</p>
|
||||
<div className="max-w-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="display-name"
|
||||
value={displayNameInput}
|
||||
onChange={(e) => setDisplayNameInput(e.target.value)}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm text-black"
|
||||
maxLength={32}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">You can use up to 32 characters.</p>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={handleUpdateDisplayName}
|
||||
disabled={isSaving || !displayNameInput || displayNameInput === profile?.display_name}
|
||||
className="px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gray-800 hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isFirebaseMode && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">API Key</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
If you want to use your own LLM API key, you can add it here. It will be used for all requests made by the local application.
|
||||
</p>
|
||||
|
||||
<div className="max-w-sm">
|
||||
<label htmlFor="api-key" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
id="api-key"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
className="flex-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm text-black"
|
||||
placeholder="Enter new API key or existing API key"
|
||||
/>
|
||||
</div>
|
||||
{hasApiKey ? (
|
||||
<p className="text-xs text-green-600 mt-2">API key is currently set.</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 mt-2">No API key set. Using free system.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={handleSaveApiKey}
|
||||
disabled={isSaving || !apiKeyInput}
|
||||
className="px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gray-800 hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-900 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isFirebaseMode || (!isFirebaseMode && !hasApiKey)) && (
|
||||
<div className="bg-white border border-red-300 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">Delete Account</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{isFirebaseMode
|
||||
? 'Permanently remove your Firebase account and all content. This action cannot be undone, so please proceed carefully.'
|
||||
: 'Permanently remove your personal account and all content from the Pickle Glass platform. This action cannot be undone, so please proceed carefully.'
|
||||
}
|
||||
</p>
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'privacy':
|
||||
return null
|
||||
default:
|
||||
return renderBillingContent()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-stone-50 min-h-screen">
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<p className="text-xs text-gray-500 mb-1">Settings</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Personal Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<nav className="flex space-x-10">
|
||||
{tabs.map((tab) => (
|
||||
<a
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
onClick={tab.id === 'privacy' ? undefined : () => setActiveTab(tab.id)}
|
||||
className={`pb-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-gray-900 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
92
pickleglass_web/app/settings/privacy/page.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useRedirectIfNotAuth } from '@/utils/auth'
|
||||
|
||||
export default function PrivacySettingsPage() {
|
||||
const userInfo = useRedirectIfNotAuth()
|
||||
|
||||
if (!userInfo) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile', name: 'Personal profile', href: '/settings' },
|
||||
{ id: 'privacy', name: 'Data & privacy', href: '/settings/privacy' },
|
||||
{ id: 'billing', name: 'Billing', href: '/settings/billing' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-stone-50 min-h-screen">
|
||||
<div className="px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<p className="text-xs text-gray-500 mb-1">Settings</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Personal settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<nav className="flex space-x-10">
|
||||
{tabs.map((tab) => (
|
||||
<a
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={`pb-4 px-2 border-b-2 font-medium text-sm transition-colors ${
|
||||
tab.id === 'privacy'
|
||||
? 'border-gray-900 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 flex flex-col">
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Privacy Policy</h3>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
Understand how we collect, use, and protect your personal information.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
onClick={() => window.open('https://www.pickle.com/ko/privacy-policy', '_blank')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Privacy
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 flex flex-col">
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Terms of Service</h3>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
Understand your rights and responsibilities when using our platform.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
onClick={() => window.open('https://www.pickle.com/ko/terms-of-service', '_blank')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Terms
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
103
pickleglass_web/backend_node/db.js
Normal file
@ -0,0 +1,103 @@
|
||||
const path = require('path');
|
||||
const databaseInitializer = require('../../src/common/services/databaseInitializer');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const dbPath = databaseInitializer.getDatabasePath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
db.exec(`
|
||||
-- users
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
uid TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at INTEGER,
|
||||
api_key TEXT
|
||||
);
|
||||
|
||||
-- sessions
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
uid TEXT NOT NULL,
|
||||
title TEXT,
|
||||
started_at INTEGER,
|
||||
ended_at INTEGER,
|
||||
sync_state TEXT DEFAULT 'clean',
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
-- transcripts
|
||||
CREATE TABLE IF NOT EXISTS transcripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
start_at INTEGER,
|
||||
end_at INTEGER,
|
||||
speaker TEXT,
|
||||
text TEXT,
|
||||
lang TEXT,
|
||||
created_at INTEGER,
|
||||
sync_state TEXT DEFAULT 'clean'
|
||||
);
|
||||
|
||||
-- ai_messages
|
||||
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
sent_at INTEGER,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
tokens INTEGER,
|
||||
model TEXT,
|
||||
created_at INTEGER,
|
||||
sync_state TEXT DEFAULT 'clean'
|
||||
);
|
||||
|
||||
-- summaries
|
||||
CREATE TABLE IF NOT EXISTS summaries (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
generated_at INTEGER,
|
||||
model TEXT,
|
||||
text TEXT,
|
||||
tldr TEXT,
|
||||
bullet_json TEXT,
|
||||
action_json TEXT,
|
||||
tokens_used INTEGER,
|
||||
updated_at INTEGER,
|
||||
sync_state TEXT DEFAULT 'clean'
|
||||
);
|
||||
|
||||
-- prompt_presets
|
||||
CREATE TABLE IF NOT EXISTS prompt_presets (
|
||||
id TEXT PRIMARY KEY,
|
||||
uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
is_default INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
sync_state TEXT DEFAULT 'clean'
|
||||
);
|
||||
`);
|
||||
|
||||
const defaultPresets = [
|
||||
['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1],
|
||||
['meetings', 'Meetings', 'You are a meeting assistant. Your goal is to help the user capture key information during meetings and follow up effectively.\n\nYou help capture meeting notes, track action items, identify key decisions, and summarize important points discussed during meetings.', 1],
|
||||
['sales', 'Sales', 'You are a real-time AI sales assistant, and your goal is to help the user close deals during sales interactions.\n\nYou provide real-time sales support, suggest responses to objections, help identify customer needs, and recommend strategies to advance deals.', 1],
|
||||
['recruiting', 'Recruiting', 'You are a recruiting assistant. Your goal is to help the user interview candidates and evaluate talent effectively.\n\nYou help evaluate candidates, suggest interview questions, analyze responses, and provide insights about candidate fit for positions.', 1],
|
||||
['customer-support', 'Customer Support', 'You are a customer support assistant. Your goal is to help resolve customer issues efficiently and thoroughly.\n\nYou help diagnose customer problems, suggest solutions, provide step-by-step troubleshooting guidance, and ensure customer satisfaction.', 1],
|
||||
];
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO prompt_presets (id, uid, title, prompt, is_default, created_at)
|
||||
VALUES (@id, 'default_user', @title, @prompt, @is_default, strftime('%s','now'));
|
||||
`);
|
||||
db.transaction(() => defaultPresets.forEach(([id, title, prompt, is_default]) => stmt.run({ id, title, prompt, is_default })))();
|
||||
|
||||
const defaultUserStmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO users (uid, display_name, email, created_at)
|
||||
VALUES ('default_user', 'Default User', 'contact@pickle.com', strftime('%s','now'));
|
||||
`);
|
||||
defaultUserStmt.run();
|
||||
|
||||
module.exports = db;
|
59
pickleglass_web/backend_node/index.js
Normal file
@ -0,0 +1,59 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const db = require('./db');
|
||||
const { identifyUser } = require('./middleware/auth');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
|
||||
console.log(`🔧 Backend CORS configured for: ${webUrl}`);
|
||||
|
||||
app.use(cors({
|
||||
origin: webUrl,
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ message: "pickleglass API is running" });
|
||||
});
|
||||
|
||||
app.use('/api', identifyUser);
|
||||
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/user', require('./routes/user'));
|
||||
app.use('/api/conversations', require('./routes/conversations'));
|
||||
app.use('/api/presets', require('./routes/presets'));
|
||||
|
||||
app.get('/api/sync/status', (req, res) => {
|
||||
res.json({
|
||||
status: 'online',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/desktop/set-user', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Direct IPC communication is now used. This endpoint is deprecated.",
|
||||
user: req.body,
|
||||
deprecated: true
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/desktop/status', (req, res) => {
|
||||
res.json({
|
||||
connected: true,
|
||||
current_user: null,
|
||||
communication_method: "IPC",
|
||||
file_based_deprecated: true
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = createApp;
|
13
pickleglass_web/backend_node/jwt.js
Normal file
@ -0,0 +1,13 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const SECRET = process.env.JWT_SECRET_KEY || 'change-me';
|
||||
const EXPIRE = 60 * 24; // minutes
|
||||
|
||||
exports.sign = (sub, extra = {}) => jwt.sign({ sub, ...extra }, SECRET, { algorithm: 'HS256', expiresIn: `${EXPIRE}m` });
|
||||
|
||||
exports.verify = token => {
|
||||
try {
|
||||
return jwt.verify(token, SECRET).sub;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
15
pickleglass_web/backend_node/middleware/auth.js
Normal file
@ -0,0 +1,15 @@
|
||||
const { verify } = require('../jwt');
|
||||
|
||||
function identifyUser(req, res, next) {
|
||||
const userId = req.get('X-User-ID');
|
||||
|
||||
if (userId) {
|
||||
req.uid = userId;
|
||||
} else {
|
||||
req.uid = 'default_user';
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { identifyUser };
|
19
pickleglass_web/backend_node/routes/auth.js
Normal file
@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
const user = db.prepare('SELECT uid, display_name FROM users WHERE uid = ?').get('default_user');
|
||||
if (!user) {
|
||||
return res.status(500).json({ error: 'Default user not initialized' });
|
||||
}
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.uid,
|
||||
name: user.display_name
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
121
pickleglass_web/backend_node/routes/conversations.js
Normal file
@ -0,0 +1,121 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
const crypto = require('crypto');
|
||||
const validator = require('validator');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const sessions = db.prepare(
|
||||
"SELECT id, uid, title, started_at, ended_at, sync_state, updated_at FROM sessions WHERE uid = ? ORDER BY started_at DESC"
|
||||
).all(req.uid);
|
||||
res.json(sessions);
|
||||
} catch (error) {
|
||||
console.error('Failed to get sessions:', error);
|
||||
res.status(500).json({ error: 'Failed to retrieve sessions' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title } = req.body;
|
||||
const sessionId = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, uid, title, started_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(sessionId, req.uid, title || 'New Conversation', now, now);
|
||||
|
||||
res.status(201).json({ id: sessionId, message: 'Session created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:session_id', (req, res) => {
|
||||
const { session_id } = req.params;
|
||||
try {
|
||||
const session = db.prepare("SELECT * FROM sessions WHERE id = ?").get(session_id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const transcripts = db.prepare("SELECT * FROM transcripts WHERE session_id = ? ORDER BY start_at ASC").all(session_id);
|
||||
const ai_messages = db.prepare("SELECT * FROM ai_messages WHERE session_id = ? ORDER BY sent_at ASC").all(session_id);
|
||||
const summary = db.prepare("SELECT * FROM summaries WHERE session_id = ?").get(session_id);
|
||||
|
||||
res.json({
|
||||
session,
|
||||
transcripts,
|
||||
ai_messages,
|
||||
summary: summary || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to get session ${session_id}:`, error);
|
||||
res.status(500).json({ error: 'Failed to retrieve session details' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:session_id', (req, res) => {
|
||||
const { session_id } = req.params;
|
||||
|
||||
const session = db.prepare("SELECT id FROM sessions WHERE id = ?").get(session_id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM transcripts WHERE session_id = ?").run(session_id);
|
||||
db.prepare("DELETE FROM ai_messages WHERE session_id = ?").run(session_id);
|
||||
db.prepare("DELETE FROM summaries WHERE session_id = ?").run(session_id);
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(session_id);
|
||||
})();
|
||||
res.status(200).json({ message: 'Session deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete session ${session_id}:`, error);
|
||||
res.status(500).json({ error: 'Failed to delete session' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/search', (req, res) => {
|
||||
const { q } = req.query;
|
||||
if (!q || !validator.isLength(q, { min: 3 })) {
|
||||
return res.status(400).json({ error: 'Query parameter "q" is required' });
|
||||
}
|
||||
// Sanitize and validate input
|
||||
const sanitizedQuery = validator.escape(q.trim()); // Escapes HTML and special chars
|
||||
if (sanitizedQuery.length === 0 || sanitizedQuery.length > 255) {
|
||||
return res.status(400).json({ error: 'Query parameter "q" must be between 3 and 255 characters' });
|
||||
}
|
||||
try {
|
||||
const searchQuery = `%${sanitizedQuery}%`;
|
||||
const sessionIds = db.prepare(`
|
||||
SELECT DISTINCT session_id FROM (
|
||||
SELECT session_id FROM transcripts WHERE text LIKE ?
|
||||
UNION
|
||||
SELECT session_id FROM ai_messages WHERE content LIKE ?
|
||||
UNION
|
||||
SELECT session_id FROM summaries WHERE text LIKE ? OR tldr LIKE ?
|
||||
)
|
||||
`).all(searchQuery, searchQuery, searchQuery, searchQuery).map(row => row.session_id);
|
||||
|
||||
if (sessionIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
const sessions = db.prepare(
|
||||
`SELECT id, uid, title, started_at, ended_at, sync_state, updated_at FROM sessions WHERE id IN (${placeholders}) ORDER BY started_at DESC`
|
||||
).all(sessionIds);
|
||||
|
||||
res.json(sessions);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
res.status(500).json({ error: 'Failed to perform search' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
87
pickleglass_web/backend_node/routes/presets.js
Normal file
@ -0,0 +1,87 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const presets = db.prepare(
|
||||
`SELECT * FROM prompt_presets
|
||||
WHERE uid = ? OR is_default = 1
|
||||
ORDER BY is_default DESC, title ASC`
|
||||
).all(req.uid);
|
||||
res.json(presets);
|
||||
} catch (error) {
|
||||
console.error('Failed to get presets:', error);
|
||||
res.status(500).json({ error: 'Failed to retrieve presets' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title, prompt } = req.body;
|
||||
if (!title || !prompt) {
|
||||
return res.status(400).json({ error: 'Title and prompt are required' });
|
||||
}
|
||||
|
||||
const presetId = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO prompt_presets (id, uid, title, prompt, is_default, created_at, sync_state)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'dirty')`
|
||||
).run(presetId, req.uid, title, prompt, now);
|
||||
|
||||
res.status(201).json({ id: presetId, message: 'Preset created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create preset:', error);
|
||||
res.status(500).json({ error: 'Failed to create preset' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { title, prompt } = req.body;
|
||||
if (!title || !prompt) {
|
||||
return res.status(400).json({ error: 'Title and prompt are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`UPDATE prompt_presets
|
||||
SET title = ?, prompt = ?, sync_state = 'dirty'
|
||||
WHERE id = ? AND uid = ? AND is_default = 0`
|
||||
).run(title, prompt, id, req.uid);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Preset not found or you don't have permission to edit it." });
|
||||
}
|
||||
|
||||
res.json({ message: 'Preset updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to update preset:', error);
|
||||
res.status(500).json({ error: 'Failed to update preset' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`DELETE FROM prompt_presets
|
||||
WHERE id = ? AND uid = ? AND is_default = 0`
|
||||
).run(id, req.uid);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Preset not found or you don't have permission to delete it." });
|
||||
}
|
||||
|
||||
res.json({ message: 'Preset deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete preset:', error);
|
||||
res.status(500).json({ error: 'Failed to delete preset' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
144
pickleglass_web/backend_node/routes/user.js
Normal file
@ -0,0 +1,144 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
|
||||
router.put('/profile', (req, res) => {
|
||||
const { displayName } = req.body;
|
||||
if (!displayName) return res.status(400).json({ error: 'displayName is required' });
|
||||
|
||||
try {
|
||||
db.prepare("UPDATE users SET display_name = ? WHERE uid = ?").run(displayName, req.uid);
|
||||
res.json({ message: 'Profile updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/profile', (req, res) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT uid, display_name, email FROM users WHERE uid = ?').get(req.uid);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Failed to get profile:', error);
|
||||
res.status(500).json({ error: 'Failed to get profile' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/find-or-create', (req, res) => {
|
||||
const { uid, displayName, email } = req.body;
|
||||
if (!uid || !displayName || !email) {
|
||||
return res.status(400).json({ error: 'uid, displayName, and email are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.prepare(
|
||||
`INSERT INTO users (uid, display_name, email, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(uid) DO NOTHING`
|
||||
).run(uid, displayName, email, now);
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE uid = ?').get(uid);
|
||||
res.status(200).json(user);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to find or create user:', error);
|
||||
res.status(500).json({ error: 'Failed to find or create user' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api-key', (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
if (typeof apiKey !== 'string') {
|
||||
return res.status(400).json({ error: 'API key must be a string' });
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare("UPDATE users SET api_key = ? WHERE uid = ?").run(apiKey, req.uid);
|
||||
res.json({ message: 'API key saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
res.status(500).json({ error: 'Failed to save API key' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api-key-status', (req, res) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT api_key FROM users WHERE uid = ?').get(req.uid);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
res.json({ hasApiKey: !!row.api_key && row.api_key.length > 0 });
|
||||
} catch (error) {
|
||||
console.error('Failed to get API key status:', error);
|
||||
res.status(500).json({ error: 'Failed to get API key status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/profile', (req, res) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT uid FROM users WHERE uid = ?').get(req.uid);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(user.uid);
|
||||
const sessionIds = userSessions.map(s => s.id);
|
||||
|
||||
db.transaction(() => {
|
||||
if (sessionIds.length > 0) {
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM transcripts WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM ai_messages WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM summaries WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM sessions WHERE uid = ?`).run(user.uid);
|
||||
}
|
||||
db.prepare('DELETE FROM prompt_presets WHERE uid = ?').run(user.uid);
|
||||
db.prepare('DELETE FROM users WHERE uid = ?').run(user.uid);
|
||||
})();
|
||||
|
||||
res.status(200).json({ message: 'User account and all data deleted successfully.' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user account:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user account' });
|
||||
}
|
||||
});
|
||||
|
||||
async function getUserBatchData(req, res) {
|
||||
const { include = 'profile,presets,sessions' } = req.query;
|
||||
|
||||
try {
|
||||
const includes = include.split(',').map(item => item.trim());
|
||||
const result = {};
|
||||
|
||||
if (includes.includes('profile')) {
|
||||
const user = db.prepare('SELECT uid, display_name, email FROM users WHERE uid = ?').get(req.uid);
|
||||
result.profile = user || null;
|
||||
}
|
||||
|
||||
if (includes.includes('presets')) {
|
||||
const presets = db.prepare('SELECT * FROM prompt_presets WHERE uid = ? OR is_default = 1').all(req.uid);
|
||||
result.presets = presets || [];
|
||||
}
|
||||
|
||||
if (includes.includes('sessions')) {
|
||||
const recent_sessions = db.prepare(
|
||||
"SELECT id, title, started_at, updated_at FROM sessions WHERE uid = ? ORDER BY updated_at DESC LIMIT 10"
|
||||
).all(req.uid);
|
||||
result.sessions = recent_sessions || [];
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to get batch data:', error);
|
||||
res.status(500).json({ error: 'Failed to get batch data' });
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/batch', getUserBatchData);
|
||||
|
||||
module.exports = router;
|
44
pickleglass_web/components/ClientLayout.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import SearchPopup from '@/components/SearchPopup'
|
||||
|
||||
export default function ClientLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggle={setIsSidebarCollapsed}
|
||||
onSearchClick={() => setIsSearchOpen(true)}
|
||||
/>
|
||||
<main className="flex-1 overflow-auto bg-white">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<SearchPopup
|
||||
isOpen={isSearchOpen}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
151
pickleglass_web/components/SearchPopup.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { searchConversations, Session } from '@/utils/api'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
|
||||
interface SearchPopupProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function SearchPopup({ isOpen, onClose }: SearchPopupProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<Session[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const results = await searchConversations(query)
|
||||
setSearchResults(results)
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value
|
||||
setSearchQuery(query)
|
||||
handleSearch(query)
|
||||
}
|
||||
|
||||
const handleBackgroundClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-25 flex items-start justify-center pt-16 z-50"
|
||||
onClick={handleBackgroundClick}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 overflow-hidden">
|
||||
<div className="flex items-center px-4 py-3">
|
||||
<Search className="h-5 w-5 text-gray-400 mr-3 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Search..."
|
||||
className="flex-1 text-gray-900 text-base border-0 focus:outline-none placeholder-gray-400 bg-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-3 p-1 hover:bg-gray-100 rounded-full flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 bg-gray-50 border-t border-gray-100">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<span>Type</span>
|
||||
<span className="mx-2 px-1.5 py-0.5 bg-white border border-gray-200 rounded text-xs font-mono">#</span>
|
||||
<span>to access summaries,</span>
|
||||
<span className="mx-2 px-1.5 py-0.5 bg-white border border-gray-200 rounded text-xs font-mono">?</span>
|
||||
<span>for help.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchQuery && (
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<p className="text-gray-500 text-sm">Searching...</p>
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{searchResults.map((result) => {
|
||||
const timestamp = new Date(result.started_at * 1000).toLocaleString()
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
className="p-3 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/activity/${result.id}`)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<MessageSquare className="h-5 w-5 text-gray-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-1 truncate">
|
||||
{result.title || 'Untitled Conversation'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-gray-500">{timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center">
|
||||
<Search className="h-8 w-8 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-sm">No results found for "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
637
pickleglass_web/components/Sidebar.tsx
Normal file
@ -0,0 +1,637 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import { useState, createElement, useEffect, useMemo, useCallback, memo } from 'react';
|
||||
import { Search, Activity, HelpCircle, Download, ChevronDown, User, Shield, Database, CreditCard, LogOut, LucideIcon } from 'lucide-react';
|
||||
import { logout, UserProfile, checkApiKeyStatus } from '@/utils/api';
|
||||
import { useAuth } from '@/utils/auth';
|
||||
|
||||
const ANIMATION_DURATION = {
|
||||
SIDEBAR: 500,
|
||||
TEXT: 300,
|
||||
SUBMENU: 500,
|
||||
ICON_HOVER: 200,
|
||||
COLOR_TRANSITION: 200,
|
||||
HOVER_SCALE: 200,
|
||||
} as const;
|
||||
|
||||
const DIMENSIONS = {
|
||||
SIDEBAR_EXPANDED: 220,
|
||||
SIDEBAR_COLLAPSED: 64,
|
||||
ICON_SIZE: 18,
|
||||
USER_AVATAR_SIZE: 32,
|
||||
HEADER_HEIGHT: 64,
|
||||
} as const;
|
||||
|
||||
const ANIMATION_DELAYS = {
|
||||
BASE: 0,
|
||||
INCREMENT: 50,
|
||||
TEXT_BASE: 250,
|
||||
SUBMENU_INCREMENT: 30,
|
||||
} as const;
|
||||
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
href?: string;
|
||||
action?: () => void;
|
||||
icon: LucideIcon | string;
|
||||
isLucide: boolean;
|
||||
hasSubmenu?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
interface SubmenuItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: LucideIcon | string;
|
||||
isLucide: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: (collapsed: boolean) => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
|
||||
interface AnimationStyles {
|
||||
text: React.CSSProperties;
|
||||
submenu: React.CSSProperties;
|
||||
sidebarContainer: React.CSSProperties;
|
||||
textContainer: React.CSSProperties;
|
||||
}
|
||||
|
||||
const useAnimationStyles = (isCollapsed: boolean) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnimating(true);
|
||||
const timer = setTimeout(() => setIsAnimating(false), ANIMATION_DURATION.SIDEBAR);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isCollapsed]);
|
||||
|
||||
const getTextAnimationStyle = useCallback(
|
||||
(delay = 0): React.CSSProperties => ({
|
||||
willChange: 'opacity',
|
||||
transition: `opacity ${ANIMATION_DURATION.TEXT}ms ease-out`,
|
||||
transitionDelay: `${delay}ms`,
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
pointerEvents: isCollapsed ? 'none' : 'auto',
|
||||
}),
|
||||
[isCollapsed]
|
||||
);
|
||||
|
||||
const getSubmenuAnimationStyle = useCallback(
|
||||
(isExpanded: boolean): React.CSSProperties => ({
|
||||
willChange: 'opacity, max-height',
|
||||
transition: `all ${ANIMATION_DURATION.SUBMENU}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`,
|
||||
maxHeight: isCollapsed || !isExpanded ? '0px' : '400px',
|
||||
opacity: isCollapsed || !isExpanded ? 0 : 1,
|
||||
}),
|
||||
[isCollapsed]
|
||||
);
|
||||
|
||||
const sidebarContainerStyle: React.CSSProperties = useMemo(
|
||||
() => ({
|
||||
willChange: 'width',
|
||||
transition: `width ${ANIMATION_DURATION.SIDEBAR}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const getTextContainerStyle = useCallback(
|
||||
(): React.CSSProperties => ({
|
||||
width: isCollapsed ? '0px' : '150px',
|
||||
overflow: 'hidden',
|
||||
transition: `width ${ANIMATION_DURATION.SIDEBAR}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
}),
|
||||
[isCollapsed]
|
||||
);
|
||||
|
||||
const getUniformTextStyle = useCallback(
|
||||
(): React.CSSProperties => ({
|
||||
willChange: 'opacity',
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
transition: `opacity 300ms ease ${isCollapsed ? '0ms' : '200ms'}`,
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}),
|
||||
[isCollapsed]
|
||||
);
|
||||
|
||||
return {
|
||||
isAnimating,
|
||||
getTextAnimationStyle,
|
||||
getSubmenuAnimationStyle,
|
||||
sidebarContainerStyle,
|
||||
getTextContainerStyle,
|
||||
getUniformTextStyle,
|
||||
};
|
||||
};
|
||||
|
||||
const IconComponent = memo<{
|
||||
icon: LucideIcon | string;
|
||||
isLucide: boolean;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}>(({ icon, isLucide, alt, className = 'h-[18px] w-[18px] transition-transform duration-200' }) => {
|
||||
if (isLucide) {
|
||||
return createElement(icon as LucideIcon, { className, 'aria-hidden': true });
|
||||
}
|
||||
|
||||
return <Image src={icon as string} alt={alt} width={18} height={18} className={className} loading="lazy" />;
|
||||
});
|
||||
|
||||
IconComponent.displayName = 'IconComponent';
|
||||
|
||||
const SidebarComponent = ({ isCollapsed, onToggle, onSearchClick }: SidebarProps) => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isSettingsExpanded, setIsSettingsExpanded] = useState(pathname.startsWith('/settings'));
|
||||
const { user: userInfo, isLoading: authLoading } = useAuth();
|
||||
const [hasApiKey, setHasApiKey] = useState<boolean | null>(null);
|
||||
|
||||
const { isAnimating, getTextAnimationStyle, getSubmenuAnimationStyle, sidebarContainerStyle, getTextContainerStyle, getUniformTextStyle } =
|
||||
useAnimationStyles(isCollapsed);
|
||||
|
||||
useEffect(() => {
|
||||
checkApiKeyStatus()
|
||||
.then(status => setHasApiKey(status.hasApiKey))
|
||||
.catch(err => {
|
||||
console.error('Failed to check API key status:', err);
|
||||
setHasApiKey(null); // Set to null on error
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith('/settings')) {
|
||||
setIsSettingsExpanded(true);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const navigation = useMemo<NavigationItem[]>(
|
||||
() => [
|
||||
{
|
||||
name: 'Search',
|
||||
action: onSearchClick,
|
||||
icon: '/search.svg',
|
||||
isLucide: false,
|
||||
ariaLabel: 'Open search',
|
||||
},
|
||||
{
|
||||
name: 'My Activity',
|
||||
href: '/activity',
|
||||
icon: '/activity.svg',
|
||||
isLucide: false,
|
||||
ariaLabel: 'View my activity',
|
||||
},
|
||||
{
|
||||
name: 'Personalize',
|
||||
href: '/personalize',
|
||||
icon: '/book.svg',
|
||||
isLucide: false,
|
||||
ariaLabel: 'Personalization settings',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
href: '/settings',
|
||||
icon: '/setting.svg',
|
||||
isLucide: false,
|
||||
hasSubmenu: true,
|
||||
ariaLabel: 'Settings menu',
|
||||
},
|
||||
],
|
||||
[onSearchClick]
|
||||
);
|
||||
|
||||
const settingsSubmenu = useMemo<SubmenuItem[]>(
|
||||
() => [
|
||||
{ name: 'Personal Profile', href: '/settings', icon: '/user.svg', isLucide: false, ariaLabel: 'Personal profile settings' },
|
||||
{ name: 'Data & privacy', href: '/settings/privacy', icon: '/privacy.svg', isLucide: false, ariaLabel: 'Data and privacy settings' },
|
||||
{ name: 'Billing', href: '/settings/billing', icon: '/credit-card.svg', isLucide: false, ariaLabel: 'Billing settings' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const bottomItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
href: 'https://discord.gg/UCZH5B5Hpd',
|
||||
icon: '/linkout.svg',
|
||||
text: 'Join Discord',
|
||||
ariaLabel: 'Help Center (new window)',
|
||||
},
|
||||
{
|
||||
href: 'https://www.dropbox.com/scl/fi/esk4h8z45sryvbremy57v/Pickle_latest.dmg?rlkey=92y535bz6p6gov6vd17x6q53b&st=9kl0annj&dl=1',
|
||||
icon: '/download.svg',
|
||||
text: 'Download Pickle Camera',
|
||||
ariaLabel: 'Download Pickle Camera (new window)',
|
||||
},
|
||||
{
|
||||
href: 'hhttps://www.dropbox.com/scl/fi/znid09apxiwtwvxer6oc9/Glass_latest.dmg?rlkey=gwvvyb3bizkl25frhs4k1zwds&st=37q31b4w&dl=1',
|
||||
icon: '/download.svg',
|
||||
text: 'Download Pickle Glass',
|
||||
ariaLabel: 'Download Pickle Glass (new window)',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
onToggle(!isCollapsed);
|
||||
}, [isCollapsed, onToggle]);
|
||||
|
||||
const toggleSettings = useCallback(() => {
|
||||
if (!pathname.startsWith('/settings')) {
|
||||
setIsSettingsExpanded(prev => !prev);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch (error) {
|
||||
console.error('An error occurred during logout:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent, action?: () => void) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
action?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderNavigationItem = useCallback(
|
||||
(item: NavigationItem, index: number) => {
|
||||
const isActive = item.href ? pathname.startsWith(item.href) : false;
|
||||
const animationDelay = 0;
|
||||
|
||||
const baseButtonClasses = `
|
||||
group flex items-center rounded-[8px] px-[12px] py-[10px] text-[14px] text-[#282828] w-full relative
|
||||
transition-colors duration-${ANIMATION_DURATION.COLOR_TRANSITION} ease-out
|
||||
focus:outline-none
|
||||
`;
|
||||
|
||||
const getStateClasses = (isActive: boolean) =>
|
||||
isActive ? 'bg-[#f2f2f2] text-[#282828]' : 'text-[#282828] hover:text-[#282828] hover:bg-[#f7f7f7]';
|
||||
|
||||
if (item.action) {
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<button
|
||||
onClick={item.action}
|
||||
onKeyDown={e => handleKeyDown(e, item.action)}
|
||||
className={`${baseButtonClasses} ${getStateClasses(false)}`}
|
||||
title={isCollapsed ? item.name : undefined}
|
||||
aria-label={item.ariaLabel || item.name}
|
||||
style={{ willChange: 'background-color, color' }}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-center w-5 h-5">
|
||||
<IconComponent icon={item.icon} isLucide={item.isLucide} alt={`${item.name} icon`} />
|
||||
</div>
|
||||
|
||||
<div className="ml-[12px] overflow-hidden" style={getTextContainerStyle()}>
|
||||
<span className="block text-left" style={getUniformTextStyle()}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.hasSubmenu) {
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<button
|
||||
onClick={toggleSettings}
|
||||
onKeyDown={e => handleKeyDown(e, toggleSettings)}
|
||||
className={`${baseButtonClasses} ${getStateClasses(isActive)}`}
|
||||
title={isCollapsed ? item.name : undefined}
|
||||
aria-label={item.ariaLabel || item.name}
|
||||
aria-expanded={isSettingsExpanded}
|
||||
aria-controls="settings-submenu"
|
||||
style={{ willChange: 'background-color, color' }}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-center w-5 h-5">
|
||||
<IconComponent icon={item.icon} isLucide={item.isLucide} alt={`${item.name} icon`} />
|
||||
</div>
|
||||
|
||||
<div className="ml-[12px] overflow-hidden flex items-center" style={getTextContainerStyle()}>
|
||||
<span className="flex-1 text-left" style={getUniformTextStyle()}>
|
||||
{item.name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="h-3 w-3 ml-1.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
willChange: 'transform, opacity',
|
||||
transition: `all ${ANIMATION_DURATION.HOVER_SCALE}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
transform: `rotate(${isSettingsExpanded ? 180 : 0}deg) ${isCollapsed ? 'scale(0)' : 'scale(1)'}`,
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="settings-submenu"
|
||||
className="overflow-hidden"
|
||||
style={getSubmenuAnimationStyle(isSettingsExpanded)}
|
||||
role="region"
|
||||
aria-labelledby="settings-button"
|
||||
>
|
||||
<ul className="mt-[4px] space-y-0 pl-[22px]" role="menu">
|
||||
{settingsSubmenu.map((subItem, subIndex) => (
|
||||
<li key={subItem.name} role="none">
|
||||
<Link
|
||||
href={subItem.href}
|
||||
className={`
|
||||
group flex items-center rounded-lg px-[12px] py-[8px] text-[13px] gap-x-[9px]
|
||||
focus:outline-none
|
||||
${
|
||||
pathname === subItem.href
|
||||
? 'bg-subtle-active-bg text-[#282828]'
|
||||
: 'text-[#282828] hover:text-[#282828] hover:bg-[#f7f7f7]'
|
||||
}
|
||||
transition-colors duration-${ANIMATION_DURATION.COLOR_TRANSITION} ease-out
|
||||
`}
|
||||
style={{
|
||||
willChange: 'background-color, color',
|
||||
}}
|
||||
role="menuitem"
|
||||
aria-label={subItem.ariaLabel || subItem.name}
|
||||
>
|
||||
<IconComponent
|
||||
icon={subItem.icon}
|
||||
isLucide={subItem.isLucide}
|
||||
alt={`${subItem.name} icon`}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="whitespace-nowrap">{subItem.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li role="none">
|
||||
{isFirebaseUser ? (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
onKeyDown={e => handleKeyDown(e, handleLogout)}
|
||||
className={`
|
||||
group flex items-center rounded-lg px-[12px] py-[8px] text-[13px] gap-x-[9px]
|
||||
text-red-600 hover:text-red-700 hover:bg-[#f7f7f7] w-full
|
||||
transition-colors duration-${ANIMATION_DURATION.COLOR_TRANSITION} ease-out
|
||||
focus:outline-none
|
||||
`}
|
||||
style={{ willChange: 'background-color, color' }}
|
||||
role="menuitem"
|
||||
aria-label="Logout"
|
||||
>
|
||||
<LogOut className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Logout</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className={`
|
||||
group flex items-center rounded-lg px-[12px] py-[8px] text-[13px] gap-x-[9px]
|
||||
text-[#282828] hover:text-[#282828] hover:bg-[#f7f7f7] w-full
|
||||
transition-colors duration-${ANIMATION_DURATION.COLOR_TRANSITION} ease-out
|
||||
focus:outline-none
|
||||
`}
|
||||
style={{ willChange: 'background-color, color' }}
|
||||
role="menuitem"
|
||||
aria-label="Login"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5 shrink-0 transform -scale-x-100" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Login</span>
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
href={item.href || '#'}
|
||||
className={`
|
||||
group flex items-center rounded-[8px] text-[14px] px-[12px] py-[10px] relative
|
||||
focus:outline-none
|
||||
${getStateClasses(isActive)}
|
||||
transition-colors duration-${ANIMATION_DURATION.COLOR_TRANSITION} ease-out
|
||||
${isCollapsed ? '' : ''}
|
||||
`}
|
||||
title={isCollapsed ? item.name : undefined}
|
||||
aria-label={item.ariaLabel || item.name}
|
||||
style={{ willChange: 'background-color, color' }}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-center w-5 h-5">
|
||||
<IconComponent icon={item.icon} isLucide={item.isLucide} alt={`${item.name} icon`} />
|
||||
</div>
|
||||
|
||||
<div className="ml-[12px] overflow-hidden" style={getTextContainerStyle()}>
|
||||
<span className="block text-left" style={getUniformTextStyle()}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
[
|
||||
pathname,
|
||||
isCollapsed,
|
||||
isSettingsExpanded,
|
||||
toggleSettings,
|
||||
handleLogout,
|
||||
handleKeyDown,
|
||||
getUniformTextStyle,
|
||||
getTextContainerStyle,
|
||||
getSubmenuAnimationStyle,
|
||||
settingsSubmenu,
|
||||
]
|
||||
);
|
||||
|
||||
const getUserDisplayName = useCallback(() => {
|
||||
if (authLoading) return 'Loading...';
|
||||
return userInfo?.display_name || 'Guest';
|
||||
}, [userInfo, authLoading]);
|
||||
|
||||
const getUserInitial = useCallback(() => {
|
||||
if (authLoading) return 'L';
|
||||
return userInfo?.display_name ? userInfo.display_name.charAt(0).toUpperCase() : 'G';
|
||||
}, [userInfo, authLoading]);
|
||||
|
||||
const isFirebaseUser = userInfo && userInfo.uid !== 'default_user';
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`flex h-full flex-col bg-white border-r py-3 px-2 border-[#e5e5e5] relative ${isCollapsed ? 'w-[60px]' : 'w-[220px]'}`}
|
||||
style={sidebarContainerStyle}
|
||||
role="navigation"
|
||||
aria-label="main navigation"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<header className={`group relative h-6 flex shrink-0 items-center justify-between`}>
|
||||
{isCollapsed ? (
|
||||
<Link href="https://pickle.com" target="_blank" rel="noopener noreferrer" className="flex items-center">
|
||||
<Image src="/symbol.svg" alt="Logo" width={20} height={20} className="mx-3 shrink-0" />
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onKeyDown={e => handleKeyDown(e, toggleSidebar)}
|
||||
className={`${
|
||||
isCollapsed ? '' : ''
|
||||
} "absolute inset-0 flex items-center justify-center text-gray-500 hover:text-gray-800 rounded-md opacity-0 scale-90 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 ease-out focus:outline-none`}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Image src="/unfold.svg" alt="Open" width={18} height={18} className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="https://pickle.com" target="_blank" rel="noopener noreferrer" className="flex items-center">
|
||||
<Image
|
||||
src={isCollapsed ? '/symbol.svg' : '/word.svg'}
|
||||
alt="pickleglass Logo"
|
||||
width={50}
|
||||
height={14}
|
||||
className="mx-3 shrink-0"
|
||||
/>
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onKeyDown={e => handleKeyDown(e, toggleSidebar)}
|
||||
className={`${
|
||||
isCollapsed ? '' : ''
|
||||
} text-gray-500 hover:text-gray-800 p-1 rounded-[4px] hover:bg-[#f7f7f7] h-6 w-6 transition-colors focus:outline-none`}
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<Image src="/unfold.svg" alt="Close" width={16} height={16} className="transform rotate-180" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<nav className="flex flex-1 flex-col pt-8" role="navigation" aria-label="Main menu">
|
||||
<ul role="list" className="flex flex-1 flex-col">
|
||||
<li>
|
||||
<ul role="list" className="">
|
||||
{navigation.map(renderNavigationItem)}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
onKeyDown={e => handleKeyDown(e, toggleSidebar)}
|
||||
className={`${
|
||||
isCollapsed ? '' : 'opacity-0'
|
||||
} "absolute inset-0 flex items-center justify-center w-full h-[36px] mb-[8px] rounded-[20px] flex justify-center items-center text-gray-500 hover:text-gray-800 rounded-md scale-90 group-hover:opacity-100 group-hover:scale-100 transition-all duration-300 ease-out focus:outline-none`}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<div className="w-[36px] h-[36px] flex items-center justify-center bg-[#f7f7f7] rounded-[20px]">
|
||||
<Image src="/unfold.svg" alt="Open" width={18} height={18} className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && hasApiKey !== null && (
|
||||
<div className="px-2.5 py-2 text-center">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${hasApiKey ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'}`}>
|
||||
{hasApiKey ? 'Local running' : 'Pickle Free System'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto space-y-[0px]" role="navigation" aria-label="Additional links">
|
||||
{bottomItems.map((item, index) => (
|
||||
<Link
|
||||
key={item.text}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`
|
||||
group flex items-center rounded-[6px] px-[12px] py-[8px] text-[13px] text-[#282828]
|
||||
hover:text-[#282828] hover:bg-[#f7f7f7] ${isCollapsed ? '' : 'gap-x-[10px]'}
|
||||
transition-colors duration-${ANIMATION_DURATION.COLOR_TRANSITION} ease-out
|
||||
focus:outline-none
|
||||
`}
|
||||
title={isCollapsed ? item.text : undefined}
|
||||
aria-label={item.ariaLabel}
|
||||
style={{ willChange: 'background-color, color' }}
|
||||
>
|
||||
<div className=" overflow-hidden">
|
||||
<span className="" style={getUniformTextStyle()}>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-center w-4 h-4">
|
||||
<IconComponent
|
||||
icon={item.icon}
|
||||
isLucide={false}
|
||||
alt={`${item.text} icon`}
|
||||
className={`h-[16px] w-[16px] transition-transform duration-${ANIMATION_DURATION.ICON_HOVER}`}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-[0px] flex items-center w-full h-[1px] px-[4px] mt-[8px] mb-[8px]">
|
||||
<div className="w-full h-[1px] bg-[#d9d9d9]"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mt-[0px] flex items-center ${isCollapsed ? '' : 'gap-x-[10px]'}`}
|
||||
style={{
|
||||
padding: isCollapsed ? '6px 8px' : '6px 8px',
|
||||
justifyContent: isCollapsed ? 'flex-start' : 'flex-start',
|
||||
transition: `all ${ANIMATION_DURATION.SIDEBAR}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
||||
}}
|
||||
role="region"
|
||||
aria-label="User profile"
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
h-[30px] w-[30px] rounded-full border border-[#8d8d8d] flex items-center justify-center text-[#282828] text-[13px]
|
||||
shrink-0 cursor-pointer transition-all duration-${ANIMATION_DURATION.ICON_HOVER}
|
||||
hover:bg-[#f7f7f7] focus:outline-none
|
||||
`}
|
||||
title={getUserDisplayName()}
|
||||
style={{ willChange: 'background-color, transform' }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`User: ${getUserDisplayName()}`}
|
||||
onKeyDown={e =>
|
||||
handleKeyDown(e, () => {
|
||||
if (isFirebaseUser) {
|
||||
router.push('/settings');
|
||||
} else {
|
||||
router.push('/login');
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
{getUserInitial()}
|
||||
</div>
|
||||
|
||||
<div className="ml-[0px] overflow-hidden" style={getTextContainerStyle()}>
|
||||
<span className="block text-[13px] leading-6 text-[#282828]" style={getUniformTextStyle()}>
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = memo(SidebarComponent);
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
|
||||
export default Sidebar;
|
35
pickleglass_web/docker-compose.yml
Normal file
@ -0,0 +1,35 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
container_name: pickleglass-backend
|
||||
restart: always
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=/app/data/pickleglass.db
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./data:/app/data
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
container_name: pickleglass-frontend
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
5
pickleglass_web/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
10
pickleglass_web/next.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
output: 'export',
|
||||
|
||||
images: { unoptimized: true },
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
32
pickleglass_web/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "pickleglass-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.6.0",
|
||||
"firebase": "^11.10.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.2.30",
|
||||
"postcss": "^8.4.32",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"tailwindcss": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
6
pickleglass_web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
19
pickleglass_web/public/README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Public Assets
|
||||
|
||||
This folder contains static files.
|
||||
|
||||
## Logo Image
|
||||
|
||||
**@symbol.svg** - Logo image for the pickleglass application
|
||||
|
||||
### Requirements:
|
||||
- Filename: `symbol.png`
|
||||
- Recommended size: 32x32px or 64x64px
|
||||
- Format: PNG
|
||||
- Transparent background recommended
|
||||
|
||||
### Usage:
|
||||
- Used as logo in sidebar header
|
||||
- Loaded optimized through Next.js Image component
|
||||
|
||||
Currently there is a placeholder file, please replace it with the actual logo image.
|
3
pickleglass_web/public/activity.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M8.57797 1.99387C8.6813 2.0972 8.74964 2.20554 8.79297 2.28554C8.8588 2.40637 8.90797 2.53554 8.94713 2.6522C9.02213 2.87554 9.0988 3.17304 9.1863 3.5122L12.253 15.3789L13.593 10.3305L13.6038 10.2889L13.6063 10.2797C13.643 10.138 13.7038 9.90137 13.8421 9.7022C13.9679 9.51896 14.1402 9.37254 14.3413 9.27804C14.5005 9.20304 14.6488 9.18054 14.758 9.1722C14.8455 9.16387 14.9413 9.1647 15.0146 9.1647H17.5013C17.7223 9.1647 17.9343 9.2525 18.0906 9.40878C18.2468 9.56506 18.3346 9.77702 18.3346 9.99804C18.3346 10.2191 18.2468 10.431 18.0906 10.5873C17.9343 10.7436 17.7223 10.8314 17.5013 10.8314H15.1846L13.6813 16.4939C13.5913 16.8322 13.513 17.1289 13.4363 17.3522C13.3939 17.4784 13.3415 17.601 13.2796 17.7189C13.1895 17.8869 13.0596 18.0302 12.9013 18.1364C12.7052 18.264 12.4761 18.3315 12.2422 18.3306C12.0083 18.3297 11.7797 18.2604 11.5846 18.1314C11.4271 18.0239 11.2984 17.8794 11.2096 17.7105C11.1485 17.5923 11.097 17.4694 11.0555 17.343C10.9805 17.1197 10.9038 16.8222 10.8163 16.483L7.74964 4.61637L6.40964 9.66387L6.3988 9.70554L6.3963 9.7147C6.35964 9.85637 6.29797 10.093 6.16047 10.2922C6.03474 10.4755 5.86244 10.6219 5.6613 10.7164C5.50214 10.7914 5.3538 10.8139 5.24464 10.8222C5.15714 10.8305 5.0613 10.8297 4.98797 10.8297H2.5013C2.28029 10.8297 2.06833 10.7419 1.91205 10.5856C1.75577 10.4293 1.66797 10.2174 1.66797 9.99637C1.66797 9.77536 1.75577 9.5634 1.91205 9.40711C2.06833 9.25083 2.28029 9.16304 2.5013 9.16304H4.81797L6.3213 3.4997C6.4113 3.1622 6.48964 2.8647 6.5663 2.64304C6.6063 2.52637 6.6563 2.3972 6.72297 2.27637C6.81297 2.10808 6.94287 1.96444 7.1013 1.85804C7.32673 1.71112 7.59512 1.64453 7.86309 1.66903C8.13105 1.69353 8.38291 1.80852 8.57797 1.99387Z" fill="#282828"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
3
pickleglass_web/public/book.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M10.0013 5.83333V17.5M10.0013 5.83333C10.0013 4.94928 9.65011 4.10143 9.02499 3.47631C8.39987 2.85119 7.55202 2.5 6.66797 2.5H2.5013C2.28029 2.5 2.06833 2.5878 1.91205 2.74408C1.75577 2.90036 1.66797 3.11232 1.66797 3.33333V14.1667C1.66797 14.3877 1.75577 14.5996 1.91205 14.7559C2.06833 14.9122 2.28029 15 2.5013 15H7.5013C8.16434 15 8.80023 15.2634 9.26907 15.7322C9.73791 16.2011 10.0013 16.837 10.0013 17.5M10.0013 5.83333C10.0013 4.94928 10.3525 4.10143 10.9776 3.47631C11.6027 2.85119 12.4506 2.5 13.3346 2.5H17.5013C17.7223 2.5 17.9343 2.5878 18.0906 2.74408C18.2468 2.90036 18.3346 3.11232 18.3346 3.33333V14.1667C18.3346 14.3877 18.2468 14.5996 18.0906 14.7559C17.9343 14.9122 17.7223 15 17.5013 15H12.5013C11.8383 15 11.2024 15.2634 10.7335 15.7322C10.2647 16.2011 10.0013 16.837 10.0013 17.5" stroke="#282828" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1000 B |
3
pickleglass_web/public/credit-card.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 4C19.7956 4 20.5587 4.31607 21.1213 4.87868C21.6839 5.44129 22 6.20435 22 7V17C22 17.7956 21.6839 18.5587 21.1213 19.1213C20.5587 19.6839 19.7956 20 19 20H5C4.20435 20 3.44129 19.6839 2.87868 19.1213C2.31607 18.5587 2 17.7956 2 17V7C2 6.20435 2.31607 5.44129 2.87868 4.87868C3.44129 4.31607 4.20435 4 5 4H19ZM20 10H4V17C4.00003 17.2449 4.08996 17.4813 4.25272 17.6644C4.41547 17.8474 4.63975 17.9643 4.883 17.993L5 18H19C19.2449 18 19.4813 17.91 19.6644 17.7473C19.8474 17.5845 19.9643 17.3603 19.993 17.117L20 17V10ZM17 13C17.2549 13.0003 17.5 13.0979 17.6854 13.2728C17.8707 13.4478 17.9822 13.687 17.9972 13.9414C18.0121 14.1958 17.9293 14.4464 17.7657 14.6418C17.6021 14.8373 17.3701 14.9629 17.117 14.993L17 15H14C13.7451 14.9997 13.5 14.9021 13.3146 14.7272C13.1293 14.5522 13.0178 14.313 13.0028 14.0586C12.9879 13.8042 13.0707 13.5536 13.2343 13.3582C13.3979 13.1627 13.6299 13.0371 13.883 13.007L14 13H17ZM19 6H5C4.73478 6 4.48043 6.10536 4.29289 6.29289C4.10536 6.48043 4 6.73478 4 7V8H20V7C20 6.73478 19.8946 6.48043 19.7071 6.29289C19.5196 6.10536 19.2652 6 19 6Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
4
pickleglass_web/public/download.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 10V2M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="#282828" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.66797 6.66406L8.0013 9.9974L11.3346 6.66406" stroke="#282828" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 494 B |
3
pickleglass_web/public/linkout.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M14 6.5C14 6.63261 13.9473 6.75979 13.8536 6.85355C13.7598 6.94732 13.6326 7 13.5 7C13.3674 7 13.2402 6.94732 13.1464 6.85355C13.0527 6.75979 13 6.63261 13 6.5V3.7075L8.85437 7.85375C8.76055 7.94757 8.63331 8.00028 8.50062 8.00028C8.36794 8.00028 8.2407 7.94757 8.14688 7.85375C8.05306 7.75993 8.00035 7.63268 8.00035 7.5C8.00035 7.36732 8.05306 7.24007 8.14688 7.14625L12.2925 3H9.5C9.36739 3 9.24021 2.94732 9.14645 2.85355C9.05268 2.75979 9 2.63261 9 2.5C9 2.36739 9.05268 2.24021 9.14645 2.14645C9.24021 2.05268 9.36739 2 9.5 2H13.5C13.6326 2 13.7598 2.05268 13.8536 2.14645C13.9473 2.24021 14 2.36739 14 2.5V6.5ZM11.5 8C11.3674 8 11.2402 8.05268 11.1464 8.14645C11.0527 8.24021 11 8.36739 11 8.5V13H3V5H7.5C7.63261 5 7.75979 4.94732 7.85355 4.85355C7.94732 4.75979 8 4.63261 8 4.5C8 4.36739 7.94732 4.24021 7.85355 4.14645C7.75979 4.05268 7.63261 4 7.5 4H3C2.73478 4 2.48043 4.10536 2.29289 4.29289C2.10536 4.48043 2 4.73478 2 5V13C2 13.2652 2.10536 13.5196 2.29289 13.7071C2.48043 13.8946 2.73478 14 3 14H11C11.2652 14 11.5196 13.8946 11.7071 13.7071C11.8946 13.5196 12 13.2652 12 13V8.5C12 8.36739 11.9473 8.24021 11.8536 8.14645C11.7598 8.05268 11.6326 8 11.5 8Z" fill="#282828"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
4
pickleglass_web/public/privacy.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3.00089L12.394 2.08089C12.2695 2.02752 12.1355 2 12 2C11.8645 2 11.7305 2.02752 11.606 2.08089L12 3.00089ZM12 21.0009L11.504 21.8689C11.6551 21.9552 11.826 22.0006 12 22.0006C12.174 22.0006 12.3449 21.9552 12.496 21.8689L12 21.0009ZM11.606 2.08189L5.212 4.82089L6 6.66089L12.394 3.92089L11.606 2.08189ZM4 6.65989V13.5199H6V6.65989H4ZM7.527 19.5969L11.504 21.8689L12.496 20.1329L8.519 17.8599L7.527 19.5969ZM12.496 21.8689L16.473 19.5969L15.481 17.8599L11.504 20.1329L12.496 21.8689ZM20 13.5189V6.66089H18V13.5209L20 13.5189ZM18.788 4.82189L12.394 2.08189L11.606 3.91989L18 6.66089L18.788 4.82189ZM20 6.66089C20 6.26955 19.8851 5.88682 19.6697 5.56011C19.4542 5.2334 19.1477 4.97608 18.788 4.82189L18 6.66089H20ZM16.473 19.5969C17.5446 18.9845 18.4353 18.1007 19.0547 17.0331C19.6741 15.9655 20.0002 14.7531 20 13.5189H18C17.9999 14.4004 17.7667 15.2662 17.3242 16.0285C16.8816 16.7909 16.2454 17.4227 15.48 17.8599L16.473 19.5969ZM4 13.5189C3.99994 14.753 4.32615 15.9652 4.94554 17.0325C5.56494 18.0999 6.45551 18.9846 7.527 19.5969L8.519 17.8599C7.75406 17.4227 7.11823 16.7912 6.67587 16.0292C6.23352 15.2673 6.00036 14.4019 6 13.5209L4 13.5189ZM5.212 4.82089C4.85216 4.97515 4.5455 5.23262 4.33005 5.55953C4.11461 5.88643 3.99985 6.26938 4 6.66089H6L5.212 4.82089Z" fill="black"/>
|
||||
<path d="M15 10L11 14L9 12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
3
pickleglass_web/public/search.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M17.5002 17.5002L13.881 13.881M13.881 13.881C14.5001 13.2619 14.9912 12.527 15.3262 11.7181C15.6612 10.9093 15.8337 10.0423 15.8337 9.16684C15.8337 8.29134 15.6612 7.42441 15.3262 6.61555C14.9912 5.80669 14.5001 5.07174 13.881 4.45267C13.2619 3.8336 12.527 3.34252 11.7181 3.00748C10.9093 2.67244 10.0423 2.5 9.16684 2.5C8.29134 2.5 7.42441 2.67244 6.61555 3.00748C5.80669 3.34252 5.07174 3.8336 4.45267 4.45267C3.2024 5.70295 2.5 7.39868 2.5 9.16684C2.5 10.935 3.2024 12.6307 4.45267 13.881C5.70295 15.1313 7.39868 15.8337 9.16684 15.8337C10.935 15.8337 12.6307 15.1313 13.881 13.881Z" stroke="#282828" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 783 B |
4
pickleglass_web/public/setting.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M7.61964 17.9888C6.24137 17.5771 4.99311 16.8158 3.99631 15.7788C4.317 15.3984 4.51692 14.931 4.57047 14.4364C4.62402 13.9417 4.52877 13.4424 4.29691 13.0022C4.06505 12.562 3.70712 12.201 3.26893 11.9654C2.83073 11.7298 2.33221 11.6303 1.83714 11.6796C1.7241 11.1272 1.66742 10.5647 1.66797 10.0008C1.66797 9.13 1.80131 8.29 2.04964 7.50083H2.08464C2.50954 7.50097 2.92746 7.39281 3.29895 7.18656C3.67043 6.98032 3.98322 6.68279 4.20778 6.32208C4.43234 5.96137 4.56125 5.54938 4.58236 5.125C4.60346 4.70063 4.51605 4.27788 4.32839 3.89667C5.30561 2.98743 6.48695 2.32625 7.77297 1.96875C7.9823 2.37975 8.30124 2.72483 8.69452 2.96581C9.08779 3.20679 9.54007 3.33428 10.0013 3.33417C10.4625 3.33428 10.9148 3.20679 11.3081 2.96581C11.7014 2.72483 12.0203 2.37975 12.2296 1.96875C13.5157 2.32625 14.697 2.98743 15.6742 3.89667C15.4852 4.28055 15.3979 4.70652 15.4206 5.13381C15.4433 5.56111 15.5753 5.97542 15.804 6.33709C16.0326 6.69877 16.3503 6.99569 16.7266 7.19945C17.1028 7.40321 17.5251 7.50698 17.953 7.50083C18.2066 8.3099 18.3353 9.15294 18.3346 10.0008C18.3346 10.5758 18.2763 11.1375 18.1655 11.68C17.6704 11.6307 17.1719 11.7302 16.7337 11.9658C16.2955 12.2014 15.9376 12.5624 15.7057 13.0026C15.4738 13.4428 15.3786 13.9421 15.4321 14.4368C15.4857 14.9314 15.6856 15.3988 16.0063 15.7792C15.0095 16.816 13.7612 17.5773 12.383 17.9888C12.2211 17.4843 11.9033 17.0442 11.4752 16.7321C11.0472 16.4199 10.5311 16.2516 10.0013 16.2516C9.47152 16.2516 8.95541 16.4199 8.52738 16.7321C8.09934 17.0442 7.78149 17.4843 7.61964 17.9888Z" stroke="#282828" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
<path d="M10.0026 12.9193C10.3856 12.9193 10.7649 12.8438 11.1188 12.6973C11.4726 12.5507 11.7942 12.3358 12.065 12.065C12.3358 11.7942 12.5507 11.4726 12.6973 11.1188C12.8438 10.7649 12.9193 10.3856 12.9193 10.0026C12.9193 9.61958 12.8438 9.24031 12.6973 8.88644C12.5507 8.53258 12.3358 8.21105 12.065 7.94021C11.7942 7.66937 11.4726 7.45453 11.1188 7.30796C10.7649 7.16138 10.3856 7.08594 10.0026 7.08594C9.22906 7.08594 8.48719 7.39323 7.94021 7.94021C7.39323 8.48719 7.08594 9.22906 7.08594 10.0026C7.08594 10.7762 7.39323 11.518 7.94021 12.065C8.48719 12.612 9.22906 12.9193 10.0026 12.9193Z" stroke="#282828" stroke-width="1.6" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
9
pickleglass_web/public/symbol.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg width="630" height="630" viewBox="0 0 630 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M351.232 220.251L349.035 223.646C347.198 226.487 350.299 229.926 353.336 228.416L356.123 227.032C356.377 226.905 356.614 226.748 356.826 226.561C359.587 224.129 364.538 219.396 364.538 217.861C364.538 216.337 366.98 214.084 368.366 213.015C368.491 212.919 368.61 212.814 368.726 212.707C370.391 211.163 371.49 211.786 372.093 212.741C372.518 213.413 372.356 214.258 372.042 214.987C370.954 217.519 372.349 216.523 370.562 221.852C369.137 226.098 371.291 226.475 372.964 226.043C373.374 225.936 373.787 225.85 374.209 225.812C378.653 225.411 389.685 221.286 395.25 219.026C395.525 218.915 395.81 218.841 396.104 218.804L403.125 217.932C403.5 217.885 403.863 217.776 404.202 217.608L415.257 212.115C415.577 211.956 415.921 211.849 416.276 211.799L422.469 210.919C422.667 210.891 422.86 210.847 423.049 210.782C430.266 208.296 440.56 201.189 444.854 197.907C448.87 193.916 457.906 185.601 461.921 181.943L472.965 171.965C476.827 168.894 485.952 158.809 490.514 153.605C490.859 153.211 491.102 152.744 491.23 152.238L492.955 145.379C493.014 145.143 493.099 144.917 493.208 144.7L496.953 137.255C497.024 137.115 497.084 136.971 497.134 136.822C498.61 132.347 500.183 125.397 500.925 121.817C501.026 121.331 501.231 120.874 501.531 120.477L503.746 117.542C503.972 117.242 504.145 116.909 504.258 116.551C506.325 110.016 510.334 96.8031 511.115 92.1456C511.918 87.3564 512.119 81.503 512.119 79.1749C511.452 71.8802 509.814 63.6213 508.704 58.7468C508.365 57.2603 507.028 56.2268 505.496 56.2268H497.06C477.446 57.0065 458.791 61.6577 451.386 64.0428C451.045 64.1527 450.69 64.2213 450.378 64.3983C449.098 65.1263 446.288 67.7725 444.854 69.1975C435.216 79.574 419.421 97.4669 412.728 105.116C410.72 107.777 405.901 114.495 402.688 120.082C399.475 125.67 381.94 154.338 373.573 167.974C370.227 173.296 362.931 185.535 360.522 191.92C358.165 198.167 353.693 212.564 351.62 219.413C351.53 219.711 351.401 219.989 351.232 220.251Z" fill="black"/>
|
||||
<path d="M425.255 287.292L419.462 293.768C418.452 294.897 418.401 296.543 418.941 297.956C419.435 299.249 419.576 300.441 419.503 301.492C419.346 303.755 419.856 306.711 421.896 307.725L423.771 308.656L433.811 312.647C438.399 314.927 446.991 318.594 451.269 320.375C451.676 320.544 452.109 320.629 452.549 320.629H462.595C462.815 320.629 463.026 320.649 463.241 320.692C467.815 321.614 486.358 325.548 495.649 327.527C495.924 327.585 496.183 327.677 496.435 327.802L505.883 332.497C506.024 332.567 506.169 332.627 506.32 332.675C512.341 334.573 521.216 333.612 526.174 334.598C529.801 335.318 532.608 336.636 533.858 337.374C534.093 337.513 534.333 337.637 534.591 337.726C539.48 339.416 550.26 342.359 557.297 341.582C564.153 340.825 572.756 338.811 576.765 337.754C577.171 337.647 577.59 337.615 578.008 337.657C581.233 337.979 587.171 338.337 589.423 337.591C591.756 336.818 596.16 333.488 598.26 331.768C598.393 331.659 598.535 331.559 598.683 331.472C608.633 325.633 617.557 326.601 620.546 324.62C622.764 323.151 624.584 321.005 625.382 319.895C625.507 319.721 625.607 319.534 625.688 319.336C629.391 310.22 629.871 295.837 629.627 288.771C629.602 288.073 629.351 287.407 628.89 286.881C622.221 279.267 609.294 272.989 603.479 270.742C597.171 267.607 585.186 264.857 579.684 263.814C579.481 263.776 579.285 263.758 579.078 263.756C570.365 263.683 555.467 260.538 548.654 258.866C548.389 258.801 548.132 258.769 547.859 258.77C524.662 258.838 480.979 264.761 477.985 265.753C475.816 266.472 470.772 269.939 467.834 272.08C467.252 272.504 466.553 272.731 465.832 272.772C462.597 272.956 456.262 273.907 449.874 276.728C442.975 279.776 431.868 284.277 426.506 286.416C426.025 286.608 425.6 286.906 425.255 287.292Z" fill="black"/>
|
||||
<path d="M397.668 397.456C399.721 394.055 402.406 386.068 403.983 380.899C404.416 379.48 405.722 378.498 407.212 378.498H420.759C423.071 378.498 429.882 382.173 433.386 384.233C433.669 384.4 433.973 384.518 434.283 384.629C437.358 385.736 442.069 390.378 444.461 393.024C444.724 393.315 445.033 393.56 445.376 393.751C453.908 398.511 459.277 404.825 460.917 407.433C464.027 412.841 473.029 420.554 477.549 424.059C477.837 424.282 478.086 424.547 478.291 424.847C483.075 431.833 491.161 440.462 494.86 444.158C494.99 444.288 495.104 444.421 495.209 444.572C499.047 450.067 502.554 460.725 503.965 465.849C504.047 466.149 504.168 466.435 504.333 466.699C506.762 470.576 511.55 476.325 513.907 479.02C514.053 479.187 514.181 479.362 514.289 479.556C517.395 485.115 521.889 497.491 523.966 503.62C524.099 504.013 524.307 504.375 524.558 504.705C528.138 509.421 529.128 517.161 529.184 520.848C529.187 521.066 529.165 521.282 529.122 521.497C527.582 529.217 525.934 539.861 525.233 544.687C525.191 544.976 525.112 545.258 524.995 545.525C522.804 550.512 519.971 555.278 518.507 557.538C518.268 557.907 517.956 558.222 517.578 558.448C512.987 561.197 507.082 561.395 504.365 561.117C504.18 561.098 503.998 561.059 503.82 561.005C493.888 557.969 485.003 551.099 484.008 550.11C483.205 549.312 470.957 537.805 464.933 532.151C462.524 529.756 457.906 526.497 455.898 525.167C452.761 522.829 428.958 497.48 416.62 484.209C416.05 483.596 415.752 482.796 415.694 481.962C415.454 478.496 414.327 475.466 413.732 474.282L404.864 454.697C404.752 454.451 404.671 454.19 404.621 453.925C403.183 446.303 401.146 442.237 400.026 440.767C399.81 440.482 399.508 440.284 399.241 440.046C397.531 438.516 394.409 432.847 392.813 429.709C392.703 429.493 392.619 429.265 392.558 429.03C391.047 423.22 388.363 417.579 386.934 414.97C386.733 414.601 386.594 414.205 386.536 413.79C385.542 406.631 384.285 393.464 386.625 393.464C388.899 393.464 389.563 395.835 389.631 397.22C389.638 397.377 389.648 397.539 389.673 397.695C390.734 404.393 394.691 402.386 397.668 397.456Z" fill="black"/>
|
||||
<path d="M297.426 454.624C295.821 456.785 294.098 457 292.558 456.338C290.174 455.312 287.357 455.338 286.863 457.875C285.731 463.682 284.767 472.146 284.32 476.934C284.229 478.955 284.549 482.565 284.799 484.838C284.877 485.541 284.737 486.252 284.389 486.869L282.953 489.413C282.807 489.672 282.696 489.947 282.631 490.237C281.547 495.074 278.55 514.823 277.144 524.391C274.71 556.133 280.08 571.302 280.909 575.32C281.554 578.452 284.05 584.438 285.309 587.248C285.374 587.393 285.428 587.536 285.469 587.689C287.114 593.827 285.636 606.458 291.337 612.611C297.109 618.839 311.986 624.477 313.955 625.56C315.923 626.643 332.902 630.383 339.994 629.703C347.085 629.023 349.277 625.139 350.283 625.184C351.089 625.22 356.374 616.828 358.916 612.628C362.495 604.825 365.267 591.014 366.206 585.083C366.349 581.905 364.877 569.763 364.123 564.089C362.923 557.365 361.358 543.21 360.587 535.64C360.485 534.641 359.914 533.746 359.318 532.936C355.742 528.073 352.154 511.954 350.694 503.772C350.111 499.031 340.426 476.689 335.47 465.693C335.339 465.403 335.256 465.109 335.208 464.796C333.855 455.972 328.171 442.621 325.453 436.959C323.546 433.481 319.955 431.615 317.699 430.88C317.033 430.663 316.307 430.733 315.722 431.117C310.551 434.505 301.519 447.944 297.426 454.624Z" fill="black"/>
|
||||
<path d="M77.0743 509.332C73.1903 513.746 64.4491 528.943 60.0923 536.839C59.7996 537.37 59.6566 537.973 59.7007 538.576C60.088 543.874 61.6558 545.857 63.0076 546.377C63.8335 546.695 64.8255 546.663 65.7108 546.679C69.6383 546.749 79.3073 544.025 84.253 542.45C86.7682 541.29 93.3173 539.392 96.6503 538.484C96.9223 538.41 97.1977 538.374 97.4795 538.362C102.726 538.138 116.699 534.941 123.198 533.327C129.53 532.019 138.125 530.879 142.251 530.398C142.704 530.345 143.136 530.204 143.533 529.981C152.306 525.054 167.194 518.318 174.184 515.286C174.544 515.13 174.869 514.914 175.156 514.647C184.354 506.098 196.758 499.753 202.359 497.41C202.69 497.272 202.997 497.088 203.266 496.852C208.792 491.987 215.23 483.208 217.811 479.342C222.877 472.862 228.532 451.752 230.726 442.006C235.529 426.324 237.861 403.248 238.494 392.741C238.532 392.116 238.387 391.495 238.081 390.948L233.261 382.326C233.086 382.014 232.865 381.729 232.589 381.499C228.509 378.1 221.714 376.788 218.222 376.5C217.818 376.467 217.414 376.517 217.033 376.655C211.695 378.587 201.221 385.169 196.493 388.328C184.598 396.982 169.015 410.647 162.71 416.397C153.275 425.338 134.07 441.558 125.415 448.744C125.26 448.872 125.131 449.002 125 449.155C121.636 453.085 107.638 467.56 100.933 474.436C97.1818 477.857 83.814 498.633 77.438 508.849C77.3282 509.025 77.2114 509.177 77.0743 509.332Z" fill="black"/>
|
||||
<path d="M213.409 250.39C217.459 258.188 228.462 269.616 234.146 279.161C234.63 279.973 234.726 280.965 234.326 281.82C231.496 287.876 226.588 286.386 223.185 291.64C223.002 291.922 222.792 292.192 222.532 292.404C219.371 294.97 215.996 294.22 209.462 296.538C209.063 296.68 208.697 296.908 208.377 297.184C202.092 302.596 199.679 299.167 190.172 298.67C189.899 298.656 189.608 298.681 189.341 298.734C179.743 300.612 186.502 296.329 178.411 298.651C163.586 302.877 163.154 303.402 156.78 303.165C156.308 303.148 155.852 303.028 155.423 302.831C146.457 298.717 124.176 295.064 120.851 294.179C120.687 294.135 120.535 294.097 120.368 294.07C113.987 293.04 99.1946 291.728 93.4054 292.038C93.0507 292.057 92.6937 292.029 92.3472 291.951C84.5621 290.198 78.0972 291.426 65.1279 285.678L48.174 277.245C35.6612 269.612 27.4656 262.01 23.3654 254.276C23.2818 254.118 23.1929 253.976 23.0885 253.831C19.9494 249.471 11.5651 233.729 8.55181 222.83C8.43847 222.42 8.25493 222.04 8.00571 221.695C5.67375 218.463 2.64419 213.408 0.540204 209.747C-0.374273 208.156 0.210121 206.135 1.88508 205.372C12.9072 200.35 29.395 197.391 36.8363 196.49C43.8441 196.021 53.2155 196.811 57.5354 197.32C57.8952 197.362 58.2538 197.347 58.6102 197.282C65.0169 196.118 76.2755 196.188 81.7988 196.392C82.2193 196.407 82.6295 196.504 83.0139 196.675L86.5972 198.263C87.1261 198.497 87.7071 198.582 88.2859 198.565C93.5449 198.411 98.8872 201.343 101.186 203.042C101.326 203.145 101.467 203.241 101.624 203.315C104.83 204.832 112.857 204.404 117.031 203.943C117.394 203.903 117.756 203.917 118.112 203.993C123.014 205.04 132.619 207.436 137.28 208.623C137.516 208.683 137.733 208.764 137.951 208.874C148.405 214.141 174.34 226.075 187.332 229.682C187.535 229.739 187.721 229.807 187.908 229.904C194.693 233.386 205.996 245.079 211.908 248.786C212.542 249.183 213.064 249.728 213.409 250.39Z" fill="black"/>
|
||||
<path d="M263.3 191.237C264.046 194.162 270.005 208.541 273.576 216.993C274.096 218.224 275.306 219.017 276.648 219.017H279.192C281.037 219.017 282.532 217.53 282.532 215.695V214.568C282.532 213.09 283.514 211.789 284.942 211.377L298.579 207.446C299.328 207.23 299.978 206.759 300.415 206.116L307.444 195.781C307.703 195.401 307.877 194.982 307.963 194.532C312.173 172.651 326.065 137.609 327.067 132.699C327.877 128.73 328.754 119.801 329.091 115.832C328.296 87.7505 318.963 54.1752 314.158 40.1552C313.992 39.6718 313.718 39.2496 313.356 38.8879C310.461 35.9997 305.05 30.2533 302.775 26.5369C300.378 22.6208 292.528 11.9423 288.754 6.89382C288.653 6.7578 288.544 6.63386 288.422 6.51489L282.971 1.17083C282.08 0.297398 280.773 -0.00748953 279.584 0.380852L278.994 0.573761C278.656 0.684276 278.341 0.844184 278.057 1.05761C266.008 10.1095 252.131 28.6488 249.13 39.4351C246.778 47.8903 242.337 66.3324 240.221 75.2986C240.089 75.8539 240.105 76.4331 240.264 76.9809L241.609 81.5941C241.885 82.5421 241.724 83.5633 241.168 84.3809L238.193 88.7546C238.062 88.9466 237.955 89.1359 237.881 89.3559C235.934 95.1709 234.979 124.856 235.972 129.722C236.782 133.691 241.708 144.605 244.069 149.566L252.167 164.448C255.541 172.055 262.491 188.062 263.3 191.237Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 11 KiB |
4
pickleglass_web/public/unfold.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="#A2A2A2" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 2V14" stroke="#A2A2A2" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 466 B |
3
pickleglass_web/public/user.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C10.6868 2 9.38642 2.25866 8.17317 2.7612C6.95991 3.26375 5.85752 4.00035 4.92893 4.92893C3.05357 6.8043 2 9.34784 2 12C2 14.6522 3.05357 17.1957 4.92893 19.0711C5.85752 19.9997 6.95991 20.7362 8.17317 21.2388C9.38642 21.7413 10.6868 22 12 22C14.6522 22 17.1957 20.9464 19.0711 19.0711C20.9464 17.1957 22 14.6522 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7362 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM7.07 18.28C7.5 17.38 10.12 16.5 12 16.5C13.88 16.5 16.5 17.38 16.93 18.28C15.5291 19.3955 13.7908 20.002 12 20C10.14 20 8.43 19.36 7.07 18.28ZM18.36 16.83C16.93 15.09 13.46 14.5 12 14.5C10.54 14.5 7.07 15.09 5.64 16.83C4.57632 15.4446 3.99982 13.7467 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 13.82 19.38 15.5 18.36 16.83ZM12 6C10.06 6 8.5 7.56 8.5 9.5C8.5 11.44 10.06 13 12 13C13.94 13 15.5 11.44 15.5 9.5C15.5 7.56 13.94 6 12 6ZM12 11C11.6022 11 11.2206 10.842 10.9393 10.5607C10.658 10.2794 10.5 9.89782 10.5 9.5C10.5 9.10218 10.658 8.72064 10.9393 8.43934C11.2206 8.15804 11.6022 8 12 8C12.3978 8 12.7794 8.15804 13.0607 8.43934C13.342 8.72064 13.5 9.10218 13.5 9.5C13.5 9.89782 13.342 10.2794 13.0607 10.5607C12.7794 10.842 12.3978 11 12 11Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
8
pickleglass_web/public/word.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="51" height="16" viewBox="0 0 51 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.134003 14.0563C0.491343 14.0563 0.772107 14.0181 0.976301 13.9416C1.1805 13.8652 1.33364 13.7569 1.43574 13.6167C1.53784 13.4765 1.60165 13.3109 1.62717 13.1198C1.65269 12.9159 1.66546 12.6993 1.66546 12.47V3.86948C1.66546 3.64013 1.65269 3.4299 1.62717 3.23878C1.60165 3.03491 1.53784 2.8629 1.43574 2.72275C1.33364 2.58259 1.1805 2.47429 0.976301 2.39784C0.772107 2.32139 0.491343 2.28317 0.134003 2.28317H0V1.5569H6.25982C7.63813 1.5569 8.68463 1.86907 9.39931 2.4934C10.1267 3.11773 10.4905 3.95867 10.4905 5.01621C10.4905 5.6278 10.3629 6.17569 10.1076 6.65986C9.86513 7.14404 9.52693 7.55176 9.09302 7.88304C8.65911 8.21432 8.155 8.46915 7.58071 8.64753C7.00641 8.82591 6.38745 8.9151 5.72382 8.9151C5.20057 8.9151 4.7858 8.89599 4.47951 8.85776C4.18598 8.8068 3.99455 8.76857 3.90521 8.74309V12.6038C3.90521 13.0497 4.02007 13.4065 4.24979 13.674C4.47951 13.9289 4.93894 14.0563 5.6281 14.0563H6.03011V14.7825H0V14.0563H0.134003ZM5.24523 7.90215C6.15134 7.90215 6.85326 7.67918 7.35099 7.23323C7.84871 6.78728 8.09757 6.10561 8.09757 5.18822C8.09757 4.38551 7.88062 3.74206 7.4467 3.25789C7.01279 2.76097 6.37469 2.51251 5.53239 2.51251H5.4941C4.89428 2.51251 4.47951 2.61444 4.24979 2.81831C4.02007 3.02217 3.90521 3.40442 3.90521 3.96504V7.76837C3.96902 7.79385 4.11579 7.8257 4.3455 7.86393C4.57522 7.88941 4.87513 7.90215 5.24523 7.90215Z" fill="black"/>
|
||||
<path d="M13.2766 3.81214C12.8809 3.81214 12.5427 3.67199 12.262 3.39167C11.9812 3.11136 11.8408 2.77371 11.8408 2.37873C11.8408 1.98374 11.9812 1.64609 12.262 1.36578C12.5427 1.08547 12.8809 0.945312 13.2766 0.945312C13.6722 0.945312 14.0104 1.08547 14.2911 1.36578C14.5719 1.64609 14.7123 1.98374 14.7123 2.37873C14.7123 2.77371 14.5719 3.11136 14.2911 3.39167C14.0104 3.67199 13.6722 3.81214 13.2766 3.81214ZM11.2665 14.0563C11.777 14.0563 12.128 13.9671 12.3194 13.7887C12.5108 13.6103 12.6065 13.3109 12.6065 12.8904V8.03594C12.6065 7.61547 12.5108 7.32242 12.3194 7.15678C12.128 6.99114 11.8344 6.90832 11.4388 6.90832H11.0177V6.27762C11.1708 6.23939 11.3814 6.18843 11.6494 6.12472C11.9302 6.06101 12.2173 5.99093 12.5108 5.91448C12.8044 5.83804 13.0851 5.76796 13.3531 5.70425C13.6211 5.6278 13.8317 5.56409 13.9849 5.51313H14.54V12.8904C14.54 13.3109 14.6357 13.6103 14.8272 13.7887C15.0186 13.9671 15.3695 14.0563 15.88 14.0563H16.014V14.7825H11.0751V14.0563H11.2665Z" fill="black"/>
|
||||
<path d="M23.6444 8.20795C23.5551 7.59636 23.3062 7.13767 22.8978 6.83187C22.5022 6.51333 21.9981 6.35407 21.3855 6.35407C21.1047 6.35407 20.8048 6.40503 20.4858 6.50696C20.1667 6.59615 19.8732 6.77453 19.6052 7.0421C19.3372 7.29693 19.1138 7.66007 18.9352 8.1315C18.7565 8.59019 18.6672 9.19541 18.6672 9.94716C18.6672 10.3549 18.7118 10.7626 18.8012 11.1703C18.9033 11.5781 19.0564 11.9412 19.2606 12.2597C19.4776 12.5783 19.7519 12.8395 20.0838 13.0433C20.4156 13.2345 20.8176 13.33 21.2898 13.33C21.9279 13.33 22.4511 13.1325 22.8595 12.7375C23.2807 12.3426 23.5423 11.7883 23.6444 11.0748H25.8267C25.6225 12.3617 25.1312 13.3491 24.3527 14.0372C23.587 14.7125 22.566 15.0501 21.2898 15.0501C20.5113 15.0501 19.8221 14.9227 19.2223 14.6679C18.6353 14.4003 18.1375 14.0435 17.7291 13.5976C17.3208 13.1389 17.0081 12.5974 16.7911 11.9731C16.5869 11.3487 16.4848 10.6734 16.4848 9.94716C16.4848 9.20815 16.5869 8.51374 16.7911 7.86393C16.9953 7.21411 17.3016 6.65349 17.71 6.18206C18.1184 5.69788 18.6225 5.32201 19.2223 5.05444C19.8349 4.77412 20.5496 4.63397 21.3664 4.63397C21.9406 4.63397 22.483 4.71042 22.9935 4.86331C23.5168 5.00347 23.9762 5.22007 24.3718 5.51313C24.7802 5.80618 25.112 6.17569 25.3673 6.62164C25.6225 7.06759 25.7757 7.59636 25.8267 8.20795H23.6444Z" fill="black"/>
|
||||
<path d="M27.2752 1.13643H29.4575V8.89599L33.401 4.90154H36.081L32.2907 8.53286L36.4448 14.7825H33.7839L30.7592 9.98538L29.4575 11.2468V14.7825H27.2752V1.13643Z" fill="black"/>
|
||||
<path d="M38.5718 1.13643H40.7541L37.9209 14.7825H35.7386L38.5718 1.13643Z" fill="black"/>
|
||||
<path d="M47.8985 8.97244C47.9113 8.90873 47.9177 8.85776 47.9177 8.81954C47.9177 8.76857 47.9177 8.71761 47.9177 8.66664V8.36085C47.9177 7.77474 47.7326 7.29693 47.3625 6.92743C46.9924 6.54519 46.4819 6.35407 45.8311 6.35407C45.0143 6.35407 44.3634 6.60252 43.8784 7.09944C43.3935 7.58362 43.0425 8.20795 42.8256 8.97244H47.8985ZM49.4874 11.7246C49.1428 12.8841 48.5621 13.7314 47.7454 14.2665C46.9286 14.7889 45.9204 15.0501 44.7208 15.0501C44.0954 15.0501 43.5147 14.9546 42.9787 14.7634C42.4555 14.5596 42.0024 14.2729 41.6196 13.9034C41.2367 13.5211 40.9368 13.0688 40.7198 12.5464C40.5029 12.0113 40.3944 11.4061 40.3944 10.7308C40.3944 9.96627 40.5156 9.2209 40.7581 8.49463C41.0006 7.76837 41.3515 7.11855 41.811 6.54519C42.2832 5.97182 42.8575 5.51313 43.5339 5.16911C44.223 4.81235 45.0079 4.63397 45.8885 4.63397C46.7691 4.63397 47.4837 4.78049 48.0325 5.07355C48.5813 5.3666 49.0088 5.73611 49.3151 6.18206C49.6214 6.62801 49.8256 7.11855 49.9277 7.65369C50.0426 8.18884 50.1 8.6985 50.1 9.18267C50.1 9.42476 50.0872 9.64773 50.0617 9.8516C50.0362 10.0555 50.0107 10.2402 49.9851 10.4059H42.615C42.6022 10.4696 42.5895 10.5269 42.5767 10.5779C42.5767 10.6288 42.5767 10.6798 42.5767 10.7308V11.0366C42.5767 11.7246 42.7554 12.2788 43.1127 12.6993C43.4828 13.1198 44.089 13.33 44.9313 13.33C45.5184 13.33 46.0225 13.1962 46.4436 12.9287C46.8648 12.6611 47.1519 12.2597 47.3051 11.7246H49.4874Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 5.3 KiB |
13
pickleglass_web/requirements.txt
Normal file
@ -0,0 +1,13 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
aiosqlite==0.19.0
|
||||
pydantic==2.5.0
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
bcrypt==4.1.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dateutil==2.8.2
|
||||
email-validator==2.1.0
|
||||
fastapi-cors==0.0.6
|
||||
PyJWT==2.8.0
|
20
pickleglass_web/tailwind.config.js
Normal file
@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#3b82f6',
|
||||
secondary: '#64748b',
|
||||
accent: '#06b6d4',
|
||||
'subtle-bg': '#f8f7f4',
|
||||
'subtle-active-bg': '#e7e5e4',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
27
pickleglass_web/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
607
pickleglass_web/utils/api.ts
Normal file
@ -0,0 +1,607 @@
|
||||
import { auth as firebaseAuth } from './firebase';
|
||||
import {
|
||||
FirestoreUserService,
|
||||
FirestoreSessionService,
|
||||
FirestoreTranscriptService,
|
||||
FirestoreAiMessageService,
|
||||
FirestoreSummaryService,
|
||||
FirestorePromptPresetService,
|
||||
FirestoreSession,
|
||||
FirestoreTranscript,
|
||||
FirestoreAiMessage,
|
||||
FirestoreSummary,
|
||||
FirestorePromptPreset
|
||||
} from './firestore';
|
||||
import { Timestamp } from 'firebase/firestore';
|
||||
|
||||
export interface UserProfile {
|
||||
uid: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
uid: string;
|
||||
title: string;
|
||||
started_at: number;
|
||||
ended_at?: number;
|
||||
sync_state: 'clean' | 'dirty';
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface Transcript {
|
||||
id: string;
|
||||
session_id: string;
|
||||
start_at: number;
|
||||
end_at?: number;
|
||||
speaker?: string;
|
||||
text: string;
|
||||
lang?: string;
|
||||
created_at: number;
|
||||
sync_state: 'clean' | 'dirty';
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
id: string;
|
||||
session_id: string;
|
||||
sent_at: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
tokens?: number;
|
||||
model?: string;
|
||||
created_at: number;
|
||||
sync_state: 'clean' | 'dirty';
|
||||
}
|
||||
|
||||
export interface Summary {
|
||||
session_id: string;
|
||||
generated_at: number;
|
||||
model?: string;
|
||||
text: string;
|
||||
tldr: string;
|
||||
bullet_json: string;
|
||||
action_json: string;
|
||||
tokens_used?: number;
|
||||
updated_at: number;
|
||||
sync_state: 'clean' | 'dirty';
|
||||
}
|
||||
|
||||
export interface PromptPreset {
|
||||
id: string;
|
||||
uid: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
is_default: 0 | 1;
|
||||
created_at: number;
|
||||
sync_state: 'clean' | 'dirty';
|
||||
}
|
||||
|
||||
export interface SessionDetails {
|
||||
session: Session;
|
||||
transcripts: Transcript[];
|
||||
ai_messages: AiMessage[];
|
||||
summary: Summary | null;
|
||||
}
|
||||
|
||||
|
||||
const isFirebaseMode = (): boolean => {
|
||||
return firebaseAuth.currentUser !== null;
|
||||
};
|
||||
|
||||
const timestampToUnix = (timestamp: Timestamp): number => {
|
||||
return timestamp.seconds * 1000 + Math.floor(timestamp.nanoseconds / 1000000);
|
||||
};
|
||||
|
||||
const unixToTimestamp = (unix: number): Timestamp => {
|
||||
return Timestamp.fromMillis(unix);
|
||||
};
|
||||
|
||||
const convertFirestoreSession = (session: { id: string } & FirestoreSession, uid: string): Session => {
|
||||
return {
|
||||
id: session.id,
|
||||
uid,
|
||||
title: session.title,
|
||||
started_at: timestampToUnix(session.startedAt),
|
||||
ended_at: session.endedAt ? timestampToUnix(session.endedAt) : undefined,
|
||||
sync_state: 'clean',
|
||||
updated_at: timestampToUnix(session.startedAt)
|
||||
};
|
||||
};
|
||||
|
||||
const convertFirestoreTranscript = (transcript: { id: string } & FirestoreTranscript): Transcript => {
|
||||
return {
|
||||
id: transcript.id,
|
||||
session_id: '',
|
||||
start_at: timestampToUnix(transcript.startAt),
|
||||
end_at: transcript.endAt ? timestampToUnix(transcript.endAt) : undefined,
|
||||
speaker: transcript.speaker,
|
||||
text: transcript.text,
|
||||
lang: transcript.lang,
|
||||
created_at: timestampToUnix(transcript.createdAt),
|
||||
sync_state: 'clean'
|
||||
};
|
||||
};
|
||||
|
||||
const convertFirestoreAiMessage = (message: { id: string } & FirestoreAiMessage): AiMessage => {
|
||||
return {
|
||||
id: message.id,
|
||||
session_id: '',
|
||||
sent_at: timestampToUnix(message.sentAt),
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
tokens: message.tokens,
|
||||
model: message.model,
|
||||
created_at: timestampToUnix(message.createdAt),
|
||||
sync_state: 'clean'
|
||||
};
|
||||
};
|
||||
|
||||
const convertFirestoreSummary = (summary: FirestoreSummary, sessionId: string): Summary => {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
generated_at: timestampToUnix(summary.generatedAt),
|
||||
model: summary.model,
|
||||
text: summary.text,
|
||||
tldr: summary.tldr,
|
||||
bullet_json: JSON.stringify(summary.bulletPoints),
|
||||
action_json: JSON.stringify(summary.actionItems),
|
||||
tokens_used: summary.tokensUsed,
|
||||
updated_at: timestampToUnix(summary.generatedAt),
|
||||
sync_state: 'clean'
|
||||
};
|
||||
};
|
||||
|
||||
const convertFirestorePreset = (preset: { id: string } & FirestorePromptPreset, uid: string): PromptPreset => {
|
||||
return {
|
||||
id: preset.id,
|
||||
uid,
|
||||
title: preset.title,
|
||||
prompt: preset.prompt,
|
||||
is_default: preset.isDefault ? 1 : 0,
|
||||
created_at: timestampToUnix(preset.createdAt),
|
||||
sync_state: 'clean'
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
let API_ORIGIN = process.env.NODE_ENV === 'development'
|
||||
? 'http://localhost:9001'
|
||||
: '';
|
||||
|
||||
const loadRuntimeConfig = async (): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch('/runtime-config.json');
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
console.log('✅ Runtime config loaded:', config);
|
||||
return config.API_URL;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Failed to load runtime config:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getApiUrlFromElectron = (): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const { ipcRenderer } = window.require?.('electron') || {};
|
||||
if (ipcRenderer) {
|
||||
try {
|
||||
const apiUrl = ipcRenderer.sendSync('get-api-url-sync');
|
||||
if (apiUrl) {
|
||||
console.log('✅ API URL from Electron IPC:', apiUrl);
|
||||
return apiUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Electron IPC failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Not in Electron environment');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let apiUrlInitialized = false;
|
||||
let initializationPromise: Promise<void> | null = null;
|
||||
|
||||
const initializeApiUrl = async () => {
|
||||
if (apiUrlInitialized) return;
|
||||
|
||||
const electronUrl = getApiUrlFromElectron();
|
||||
if (electronUrl) {
|
||||
API_ORIGIN = electronUrl;
|
||||
apiUrlInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeUrl = await loadRuntimeConfig();
|
||||
if (runtimeUrl) {
|
||||
API_ORIGIN = runtimeUrl;
|
||||
apiUrlInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📍 Using fallback API URL:', API_ORIGIN);
|
||||
apiUrlInitialized = true;
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
initializationPromise = initializeApiUrl();
|
||||
}
|
||||
|
||||
const userInfoListeners: Array<(userInfo: UserProfile | null) => void> = [];
|
||||
|
||||
export const getUserInfo = (): UserProfile | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const storedUserInfo = localStorage.getItem('pickleglass_user');
|
||||
if (storedUserInfo) {
|
||||
try {
|
||||
return JSON.parse(storedUserInfo);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user info:', error);
|
||||
localStorage.removeItem('pickleglass_user');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setUserInfo = (userInfo: UserProfile | null, skipEvents: boolean = false) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (userInfo) {
|
||||
localStorage.setItem('pickleglass_user', JSON.stringify(userInfo));
|
||||
} else {
|
||||
localStorage.removeItem('pickleglass_user');
|
||||
}
|
||||
|
||||
if (!skipEvents) {
|
||||
userInfoListeners.forEach(listener => listener(userInfo));
|
||||
|
||||
window.dispatchEvent(new Event('userInfoChanged'));
|
||||
}
|
||||
};
|
||||
|
||||
export const onUserInfoChange = (listener: (userInfo: UserProfile | null) => void) => {
|
||||
userInfoListeners.push(listener);
|
||||
|
||||
return () => {
|
||||
const index = userInfoListeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
userInfoListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const getApiHeaders = (): HeadersInit => {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const userInfo = getUserInfo();
|
||||
if (userInfo?.uid) {
|
||||
headers['X-User-ID'] = userInfo.uid;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
|
||||
export const apiCall = async (path: string, options: RequestInit = {}) => {
|
||||
if (!apiUrlInitialized && initializationPromise) {
|
||||
await initializationPromise;
|
||||
}
|
||||
|
||||
if (!apiUrlInitialized) {
|
||||
await initializeApiUrl();
|
||||
}
|
||||
|
||||
const url = `${API_ORIGIN}${path}`;
|
||||
console.log('🌐 apiCall (Local Mode):', {
|
||||
path,
|
||||
API_ORIGIN,
|
||||
fullUrl: url,
|
||||
initialized: apiUrlInitialized,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const defaultOpts: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getApiHeaders(),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
};
|
||||
return fetch(url, defaultOpts);
|
||||
};
|
||||
|
||||
|
||||
export const searchConversations = async (query: string): Promise<Session[]> => {
|
||||
if (!query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isFirebaseMode()) {
|
||||
const sessions = await getSessions();
|
||||
return sessions.filter(session =>
|
||||
session.title.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
} else {
|
||||
const response = await apiCall(`/api/conversations/search?q=${encodeURIComponent(query)}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search conversations');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const getSessions = async (): Promise<Session[]> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
const firestoreSessions = await FirestoreSessionService.getSessions(uid);
|
||||
return firestoreSessions.map(session => convertFirestoreSession(session, uid));
|
||||
} else {
|
||||
const response = await apiCall(`/api/conversations`, { method: 'GET' });
|
||||
if (!response.ok) throw new Error('Failed to fetch sessions');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const getSessionDetails = async (sessionId: string): Promise<SessionDetails> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
|
||||
const [session, transcripts, aiMessages, summary] = await Promise.all([
|
||||
FirestoreSessionService.getSession(uid, sessionId),
|
||||
FirestoreTranscriptService.getTranscripts(uid, sessionId),
|
||||
FirestoreAiMessageService.getAiMessages(uid, sessionId),
|
||||
FirestoreSummaryService.getSummary(uid, sessionId)
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
return {
|
||||
session: convertFirestoreSession({ id: sessionId, ...session }, uid),
|
||||
transcripts: transcripts.map(t => ({ ...convertFirestoreTranscript(t), session_id: sessionId })),
|
||||
ai_messages: aiMessages.map(m => ({ ...convertFirestoreAiMessage(m), session_id: sessionId })),
|
||||
summary: summary ? convertFirestoreSummary(summary, sessionId) : null
|
||||
};
|
||||
} else {
|
||||
const response = await apiCall(`/api/conversations/${sessionId}`, { method: 'GET' });
|
||||
if (!response.ok) throw new Error('Failed to fetch session details');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const createSession = async (title?: string): Promise<{ id: string }> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
const sessionId = await FirestoreSessionService.createSession(uid, {
|
||||
title: title || 'New Session',
|
||||
endedAt: undefined
|
||||
});
|
||||
return { id: sessionId };
|
||||
} else {
|
||||
const response = await apiCall(`/api/conversations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create session');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSession = async (sessionId: string): Promise<void> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
await FirestoreSessionService.deleteSession(uid, sessionId);
|
||||
} else {
|
||||
const response = await apiCall(`/api/conversations/${sessionId}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete session');
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserProfile = async (): Promise<UserProfile> => {
|
||||
if (isFirebaseMode()) {
|
||||
const user = firebaseAuth.currentUser!;
|
||||
const firestoreProfile = await FirestoreUserService.getUser(user.uid);
|
||||
|
||||
return {
|
||||
uid: user.uid,
|
||||
display_name: firestoreProfile?.displayName || user.displayName || 'User',
|
||||
email: firestoreProfile?.email || user.email || 'no-email@example.com'
|
||||
};
|
||||
} else {
|
||||
const response = await apiCall(`/api/user/profile`, { method: 'GET' });
|
||||
if (!response.ok) throw new Error('Failed to fetch user profile');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUserProfile = async (data: { displayName: string }): Promise<void> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
await FirestoreUserService.updateUser(uid, { displayName: data.displayName });
|
||||
} else {
|
||||
const response = await apiCall(`/api/user/profile`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update user profile');
|
||||
}
|
||||
};
|
||||
|
||||
export const findOrCreateUser = async (user: UserProfile): Promise<UserProfile> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
const existingUser = await FirestoreUserService.getUser(uid);
|
||||
|
||||
if (!existingUser) {
|
||||
await FirestoreUserService.createUser(uid, {
|
||||
displayName: user.display_name,
|
||||
email: user.email
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} else {
|
||||
const response = await apiCall(`/api/user/find-or-create`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(user),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to find or create user');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const saveApiKey = async (apiKey: string): Promise<void> => {
|
||||
if (isFirebaseMode()) {
|
||||
console.log('API key is not needed in Firebase mode');
|
||||
return;
|
||||
} else {
|
||||
const response = await apiCall(`/api/user/api-key`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to save API key');
|
||||
}
|
||||
};
|
||||
|
||||
export const checkApiKeyStatus = async (): Promise<{ hasApiKey: boolean }> => {
|
||||
if (isFirebaseMode()) {
|
||||
return { hasApiKey: true };
|
||||
} else {
|
||||
const response = await apiCall(`/api/user/api-key-status`, { method: 'GET' });
|
||||
if (!response.ok) throw new Error('Failed to check API key status');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteAccount = async (): Promise<void> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
|
||||
await FirestoreUserService.deleteUser(uid);
|
||||
|
||||
await firebaseAuth.currentUser!.delete();
|
||||
} else {
|
||||
const response = await apiCall(`/api/user/profile`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete account');
|
||||
}
|
||||
};
|
||||
|
||||
export const getPresets = async (): Promise<PromptPreset[]> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
const firestorePresets = await FirestorePromptPresetService.getPresets(uid);
|
||||
return firestorePresets.map(preset => convertFirestorePreset(preset, uid));
|
||||
} else {
|
||||
const response = await apiCall(`/api/presets`, { method: 'GET' });
|
||||
if (!response.ok) throw new Error('Failed to fetch presets');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const createPreset = async (data: { title: string, prompt: string }): Promise<{ id: string }> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
const presetId = await FirestorePromptPresetService.createPreset(uid, {
|
||||
title: data.title,
|
||||
prompt: data.prompt,
|
||||
isDefault: false
|
||||
});
|
||||
return { id: presetId };
|
||||
} else {
|
||||
const response = await apiCall(`/api/presets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create preset');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePreset = async (id: string, data: { title: string, prompt: string }): Promise<void> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
await FirestorePromptPresetService.updatePreset(uid, id, {
|
||||
title: data.title,
|
||||
prompt: data.prompt
|
||||
});
|
||||
} else {
|
||||
const response = await apiCall(`/api/presets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update preset');
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePreset = async (id: string): Promise<void> => {
|
||||
if (isFirebaseMode()) {
|
||||
const uid = firebaseAuth.currentUser!.uid;
|
||||
await FirestorePromptPresetService.deletePreset(uid, id);
|
||||
} else {
|
||||
const response = await apiCall(`/api/presets/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to delete preset');
|
||||
}
|
||||
};
|
||||
|
||||
export interface BatchData {
|
||||
profile?: UserProfile;
|
||||
presets?: PromptPreset[];
|
||||
sessions?: Session[];
|
||||
}
|
||||
|
||||
export const getBatchData = async (includes: ('profile' | 'presets' | 'sessions')[]): Promise<BatchData> => {
|
||||
if (isFirebaseMode()) {
|
||||
const result: BatchData = {};
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
if (includes.includes('profile')) {
|
||||
promises.push(getUserProfile().then(profile => ({ type: 'profile', data: profile })));
|
||||
}
|
||||
if (includes.includes('presets')) {
|
||||
promises.push(getPresets().then(presets => ({ type: 'presets', data: presets })));
|
||||
}
|
||||
if (includes.includes('sessions')) {
|
||||
promises.push(getSessions().then(sessions => ({ type: 'sessions', data: sessions })));
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
results.forEach(({ type, data }) => {
|
||||
result[type as keyof BatchData] = data;
|
||||
});
|
||||
|
||||
return result;
|
||||
} else {
|
||||
const response = await apiCall(`/api/user/batch?include=${includes.join(',')}`, { method: 'GET' });
|
||||
if (!response.ok) throw new Error('Failed to fetch batch data');
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
if (isFirebaseMode()) {
|
||||
const { signOut } = await import('firebase/auth');
|
||||
await signOut(firebaseAuth);
|
||||
}
|
||||
|
||||
setUserInfo(null);
|
||||
|
||||
localStorage.removeItem('openai_api_key');
|
||||
localStorage.removeItem('user_info');
|
||||
|
||||
window.location.href = '/login';
|
||||
};
|
76
pickleglass_web/utils/auth.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { UserProfile, setUserInfo, findOrCreateUser } from './api'
|
||||
import { auth as firebaseAuth } from './firebase'
|
||||
import { onAuthStateChanged, User as FirebaseUser } from 'firebase/auth'
|
||||
|
||||
const defaultLocalUser: UserProfile = {
|
||||
uid: 'default_user',
|
||||
display_name: 'Default User',
|
||||
email: 'contact@pickle.com',
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const [user, setUser] = useState<UserProfile | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [mode, setMode] = useState<'local' | 'firebase' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(firebaseAuth, async (firebaseUser: FirebaseUser | null) => {
|
||||
if (firebaseUser) {
|
||||
console.log('🔥 Firebase mode activated:', firebaseUser.uid);
|
||||
setMode('firebase');
|
||||
|
||||
let profile: UserProfile = {
|
||||
uid: firebaseUser.uid,
|
||||
display_name: firebaseUser.displayName || 'User',
|
||||
email: firebaseUser.email || 'no-email@example.com',
|
||||
};
|
||||
|
||||
try {
|
||||
profile = await findOrCreateUser(profile);
|
||||
console.log('✅ Firestore user created/verified:', profile);
|
||||
} catch (error) {
|
||||
console.error('❌ Firestore user creation/verification failed:', error);
|
||||
}
|
||||
|
||||
setUser(profile);
|
||||
setUserInfo(profile);
|
||||
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.send('set-current-user', profile.uid);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('🏠 Local mode activated');
|
||||
setMode('local');
|
||||
|
||||
setUser(defaultLocalUser);
|
||||
setUserInfo(defaultLocalUser);
|
||||
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.send('set-current-user', defaultLocalUser.uid);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [])
|
||||
|
||||
return { user, isLoading, mode }
|
||||
}
|
||||
|
||||
export const useRedirectIfNotAuth = () => {
|
||||
const { user, isLoading } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// This hook is now simplified. It doesn't redirect for local mode.
|
||||
// If you want to force login for hosting mode, you'd add logic here.
|
||||
// For example: if (!isLoading && !user) router.push('/login');
|
||||
// But for now, we allow both modes.
|
||||
}, [user, isLoading, router])
|
||||
|
||||
return user
|
||||
}
|
23
pickleglass_web/utils/firebase.ts
Normal file
@ -0,0 +1,23 @@
|
||||
// Import the functions you need from the SDKs you need
|
||||
import { initializeApp, getApp, getApps } from "firebase/app";
|
||||
import { getAuth } from "firebase/auth";
|
||||
import { getFirestore } from "firebase/firestore";
|
||||
// import { getAnalytics } from "firebase/analytics";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g",
|
||||
authDomain: "pickle-3651a.firebaseapp.com",
|
||||
projectId: "pickle-3651a",
|
||||
storageBucket: "pickle-3651a.firebasestorage.app",
|
||||
messagingSenderId: "904706892885",
|
||||
appId: "1:904706892885:web:0e42b3dda796674ead20dc",
|
||||
measurementId: "G-SQ0WM6S28T"
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
|
||||
const auth = getAuth(app);
|
||||
const firestore = getFirestore(app);
|
||||
// const analytics = getAnalytics(app);
|
||||
|
||||
export { app, auth, firestore };
|
260
pickleglass_web/utils/firestore.ts
Normal file
@ -0,0 +1,260 @@
|
||||
import {
|
||||
doc,
|
||||
collection,
|
||||
addDoc,
|
||||
getDoc,
|
||||
getDocs,
|
||||
setDoc,
|
||||
updateDoc,
|
||||
deleteDoc,
|
||||
query,
|
||||
where,
|
||||
orderBy,
|
||||
serverTimestamp,
|
||||
Timestamp,
|
||||
writeBatch
|
||||
} from 'firebase/firestore';
|
||||
import { firestore } from './firebase';
|
||||
|
||||
export interface FirestoreUserProfile {
|
||||
displayName: string;
|
||||
email: string;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
|
||||
export interface FirestoreSession {
|
||||
title: string;
|
||||
startedAt: Timestamp;
|
||||
endedAt?: Timestamp;
|
||||
}
|
||||
|
||||
export interface FirestoreTranscript {
|
||||
startAt: Timestamp;
|
||||
endAt: Timestamp;
|
||||
speaker: 'me' | 'other';
|
||||
text: string;
|
||||
lang?: string;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
|
||||
export interface FirestoreAiMessage {
|
||||
sentAt: Timestamp;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
tokens?: number;
|
||||
model?: string;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
|
||||
export interface FirestoreSummary {
|
||||
generatedAt: Timestamp;
|
||||
model: string;
|
||||
text: string;
|
||||
tldr: string;
|
||||
bulletPoints: string[];
|
||||
actionItems: Array<{ owner: string; task: string; due: string }>;
|
||||
tokensUsed?: number;
|
||||
}
|
||||
|
||||
export interface FirestorePromptPreset {
|
||||
title: string;
|
||||
prompt: string;
|
||||
isDefault: boolean;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
|
||||
export class FirestoreUserService {
|
||||
static async createUser(uid: string, profile: Omit<FirestoreUserProfile, 'createdAt'>) {
|
||||
const userRef = doc(firestore, 'users', uid);
|
||||
await setDoc(userRef, {
|
||||
...profile,
|
||||
createdAt: serverTimestamp()
|
||||
});
|
||||
}
|
||||
|
||||
static async getUser(uid: string): Promise<FirestoreUserProfile | null> {
|
||||
const userRef = doc(firestore, 'users', uid);
|
||||
const userSnap = await getDoc(userRef);
|
||||
return userSnap.exists() ? userSnap.data() as FirestoreUserProfile : null;
|
||||
}
|
||||
|
||||
static async updateUser(uid: string, updates: Partial<FirestoreUserProfile>) {
|
||||
const userRef = doc(firestore, 'users', uid);
|
||||
await updateDoc(userRef, updates);
|
||||
}
|
||||
|
||||
static async deleteUser(uid: string) {
|
||||
const batch = writeBatch(firestore);
|
||||
|
||||
const sessionsRef = collection(firestore, 'users', uid, 'sessions');
|
||||
const sessionsSnap = await getDocs(sessionsRef);
|
||||
|
||||
for (const sessionDoc of sessionsSnap.docs) {
|
||||
const sessionId = sessionDoc.id;
|
||||
|
||||
const transcriptsRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'transcripts');
|
||||
const transcriptsSnap = await getDocs(transcriptsRef);
|
||||
transcriptsSnap.docs.forEach(doc => batch.delete(doc.ref));
|
||||
|
||||
const aiMessagesRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'aiMessages');
|
||||
const aiMessagesSnap = await getDocs(aiMessagesRef);
|
||||
aiMessagesSnap.docs.forEach(doc => batch.delete(doc.ref));
|
||||
|
||||
const summaryRef = doc(firestore, 'users', uid, 'sessions', sessionId, 'summary', 'data');
|
||||
batch.delete(summaryRef);
|
||||
|
||||
batch.delete(sessionDoc.ref);
|
||||
}
|
||||
|
||||
const presetsRef = collection(firestore, 'users', uid, 'promptPresets');
|
||||
const presetsSnap = await getDocs(presetsRef);
|
||||
presetsSnap.docs.forEach(doc => batch.delete(doc.ref));
|
||||
|
||||
const userRef = doc(firestore, 'users', uid);
|
||||
batch.delete(userRef);
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
||||
|
||||
export class FirestoreSessionService {
|
||||
static async createSession(uid: string, session: Omit<FirestoreSession, 'startedAt'>): Promise<string> {
|
||||
const sessionsRef = collection(firestore, 'users', uid, 'sessions');
|
||||
const docRef = await addDoc(sessionsRef, {
|
||||
...session,
|
||||
startedAt: serverTimestamp()
|
||||
});
|
||||
return docRef.id;
|
||||
}
|
||||
|
||||
static async getSession(uid: string, sessionId: string): Promise<FirestoreSession | null> {
|
||||
const sessionRef = doc(firestore, 'users', uid, 'sessions', sessionId);
|
||||
const sessionSnap = await getDoc(sessionRef);
|
||||
return sessionSnap.exists() ? sessionSnap.data() as FirestoreSession : null;
|
||||
}
|
||||
|
||||
static async getSessions(uid: string): Promise<Array<{ id: string } & FirestoreSession>> {
|
||||
const sessionsRef = collection(firestore, 'users', uid, 'sessions');
|
||||
const q = query(sessionsRef, orderBy('startedAt', 'desc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
return querySnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data() as FirestoreSession
|
||||
}));
|
||||
}
|
||||
|
||||
static async updateSession(uid: string, sessionId: string, updates: Partial<FirestoreSession>) {
|
||||
const sessionRef = doc(firestore, 'users', uid, 'sessions', sessionId);
|
||||
await updateDoc(sessionRef, updates);
|
||||
}
|
||||
|
||||
static async deleteSession(uid: string, sessionId: string) {
|
||||
const batch = writeBatch(firestore);
|
||||
|
||||
const transcriptsRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'transcripts');
|
||||
const transcriptsSnap = await getDocs(transcriptsRef);
|
||||
transcriptsSnap.docs.forEach(doc => batch.delete(doc.ref));
|
||||
|
||||
const aiMessagesRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'aiMessages');
|
||||
const aiMessagesSnap = await getDocs(aiMessagesRef);
|
||||
aiMessagesSnap.docs.forEach(doc => batch.delete(doc.ref));
|
||||
|
||||
const summaryRef = doc(firestore, 'users', uid, 'sessions', sessionId, 'summary', 'data');
|
||||
batch.delete(summaryRef);
|
||||
|
||||
const sessionRef = doc(firestore, 'users', uid, 'sessions', sessionId);
|
||||
batch.delete(sessionRef);
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
||||
|
||||
export class FirestoreTranscriptService {
|
||||
static async addTranscript(uid: string, sessionId: string, transcript: Omit<FirestoreTranscript, 'createdAt'>): Promise<string> {
|
||||
const transcriptsRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'transcripts');
|
||||
const docRef = await addDoc(transcriptsRef, {
|
||||
...transcript,
|
||||
createdAt: serverTimestamp()
|
||||
});
|
||||
return docRef.id;
|
||||
}
|
||||
|
||||
static async getTranscripts(uid: string, sessionId: string): Promise<Array<{ id: string } & FirestoreTranscript>> {
|
||||
const transcriptsRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'transcripts');
|
||||
const q = query(transcriptsRef, orderBy('startAt', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
return querySnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data() as FirestoreTranscript
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export class FirestoreAiMessageService {
|
||||
static async addAiMessage(uid: string, sessionId: string, message: Omit<FirestoreAiMessage, 'createdAt'>): Promise<string> {
|
||||
const aiMessagesRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'aiMessages');
|
||||
const docRef = await addDoc(aiMessagesRef, {
|
||||
...message,
|
||||
createdAt: serverTimestamp()
|
||||
});
|
||||
return docRef.id;
|
||||
}
|
||||
|
||||
static async getAiMessages(uid: string, sessionId: string): Promise<Array<{ id: string } & FirestoreAiMessage>> {
|
||||
const aiMessagesRef = collection(firestore, 'users', uid, 'sessions', sessionId, 'aiMessages');
|
||||
const q = query(aiMessagesRef, orderBy('sentAt', 'asc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
return querySnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data() as FirestoreAiMessage
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export class FirestoreSummaryService {
|
||||
static async setSummary(uid: string, sessionId: string, summary: FirestoreSummary) {
|
||||
const summaryRef = doc(firestore, 'users', uid, 'sessions', sessionId, 'summary', 'data');
|
||||
await setDoc(summaryRef, summary);
|
||||
}
|
||||
|
||||
static async getSummary(uid: string, sessionId: string): Promise<FirestoreSummary | null> {
|
||||
const summaryRef = doc(firestore, 'users', uid, 'sessions', sessionId, 'summary', 'data');
|
||||
const summarySnap = await getDoc(summaryRef);
|
||||
return summarySnap.exists() ? summarySnap.data() as FirestoreSummary : null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FirestorePromptPresetService {
|
||||
static async createPreset(uid: string, preset: Omit<FirestorePromptPreset, 'createdAt'>): Promise<string> {
|
||||
const presetsRef = collection(firestore, 'users', uid, 'promptPresets');
|
||||
const docRef = await addDoc(presetsRef, {
|
||||
...preset,
|
||||
createdAt: serverTimestamp()
|
||||
});
|
||||
return docRef.id;
|
||||
}
|
||||
|
||||
static async getPresets(uid: string): Promise<Array<{ id: string } & FirestorePromptPreset>> {
|
||||
const presetsRef = collection(firestore, 'users', uid, 'promptPresets');
|
||||
const q = query(presetsRef, orderBy('createdAt', 'desc'));
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
return querySnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data() as FirestorePromptPreset
|
||||
}));
|
||||
}
|
||||
|
||||
static async updatePreset(uid: string, presetId: string, updates: Partial<FirestorePromptPreset>) {
|
||||
const presetRef = doc(firestore, 'users', uid, 'promptPresets', presetId);
|
||||
await updateDoc(presetRef, updates);
|
||||
}
|
||||
|
||||
static async deletePreset(uid: string, presetId: string) {
|
||||
const presetRef = doc(firestore, 'users', uid, 'promptPresets', presetId);
|
||||
await deleteDoc(presetRef);
|
||||
}
|
||||
}
|
BIN
public/assets/00.gif
Normal file
After Width: | Height: | Size: 6.3 MiB |
BIN
public/assets/01.gif
Normal file
After Width: | Height: | Size: 16 MiB |
BIN
public/assets/02.gif
Normal file
After Width: | Height: | Size: 3.7 MiB |
BIN
public/assets/03.gif
Normal file
After Width: | Height: | Size: 13 MiB |
BIN
public/assets/banner.gif
Normal file
After Width: | Height: | Size: 7.8 MiB |
BIN
public/assets/banner.png
Normal file
After Width: | Height: | Size: 4.5 MiB |
BIN
public/assets/button_dc.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
public/assets/button_we.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/button_xe.png
Normal file
After Width: | Height: | Size: 18 KiB |
3
public/assets/dompurify-3.0.7.min.js
vendored
Normal file
5
public/assets/icon-listen.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.69922 2.7515C1.69922 2.37153 2.00725 2.0635 2.38722 2.0635H2.73122C3.11119 2.0635 3.41922 2.37153 3.41922 2.7515V8.2555C3.41922 8.63547 3.11119 8.9435 2.73122 8.9435H2.38722C2.00725 8.9435 1.69922 8.63547 1.69922 8.2555V2.7515Z" fill="white"/>
|
||||
<path d="M5.13922 1.3755C5.13922 0.995528 5.44725 0.6875 5.82722 0.6875H6.17122C6.55119 0.6875 6.85922 0.995528 6.85922 1.3755V9.6315C6.85922 10.0115 6.55119 10.3195 6.17122 10.3195H5.82722C5.44725 10.3195 5.13922 10.0115 5.13922 9.6315V1.3755Z" fill="white"/>
|
||||
<path d="M8.57922 3.0955C8.57922 2.71553 8.88725 2.4075 9.26722 2.4075H9.61122C9.99119 2.4075 10.2992 2.71553 10.2992 3.0955V7.9115C10.2992 8.29147 9.99119 8.5995 9.61122 8.5995H9.26722C8.88725 8.5995 8.57922 8.29147 8.57922 7.9115V3.0955Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 876 B |
BIN
public/assets/product_shot.png
Normal file
After Width: | Height: | Size: 2.9 MiB |
BIN
public/assets/star-history-202574.png
Normal file
After Width: | Height: | Size: 334 KiB |
3
public/assets/streamline_incognito-mode-remix.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.785 7.41787C8.7 7.41787 7.79 8.19371 7.55667 9.22621C7.0025 8.98704 6.495 9.05121 6.11 9.22037C5.87083 8.18204 4.96083 7.41787 3.88167 7.41787C2.61583 7.41787 1.58333 8.46204 1.58333 9.75121C1.58333 11.0404 2.61583 12.0845 3.88167 12.0845C5.08333 12.0845 6.06333 11.1395 6.15667 9.93787C6.355 9.79787 6.87417 9.53537 7.51 9.94954C7.615 11.1454 8.58333 12.0845 9.785 12.0845C11.0508 12.0845 12.0833 11.0404 12.0833 9.75121C12.0833 8.46204 11.0508 7.41787 9.785 7.41787ZM3.88167 11.4195C2.97167 11.4195 2.2425 10.6729 2.2425 9.75121C2.2425 8.82954 2.9775 8.08287 3.88167 8.08287C4.79167 8.08287 5.52083 8.82954 5.52083 9.75121C5.52083 10.6729 4.79167 11.4195 3.88167 11.4195ZM9.785 11.4195C8.875 11.4195 8.14583 10.6729 8.14583 9.75121C8.14583 8.82954 8.875 8.08287 9.785 8.08287C10.695 8.08287 11.43 8.82954 11.43 9.75121C11.43 10.6729 10.6892 11.4195 9.785 11.4195ZM12.6667 5.95954H1V6.83454H12.6667V5.95954ZM8.8925 1.36871C8.76417 1.08287 8.4375 0.931207 8.12833 1.03037L6.83333 1.46204L5.5325 1.03037L5.50333 1.02454C5.19417 0.93704 4.8675 1.10037 4.75083 1.39787L3.33333 5.08454H10.3333L8.91 1.39787L8.8925 1.36871Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
3
public/assets/tabler_dots.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.0013 3.16406C7.82449 3.16406 7.65492 3.2343 7.5299 3.35932C7.40487 3.48435 7.33464 3.65392 7.33464 3.83073C7.33464 4.00754 7.40487 4.17711 7.5299 4.30213C7.65492 4.42716 7.82449 4.4974 8.0013 4.4974C8.17811 4.4974 8.34768 4.42716 8.47271 4.30213C8.59773 4.17711 8.66797 4.00754 8.66797 3.83073C8.66797 3.65392 8.59773 3.48435 8.47271 3.35932C8.34768 3.2343 8.17811 3.16406 8.0013 3.16406ZM8.0013 7.83073C7.82449 7.83073 7.65492 7.90097 7.5299 8.02599C7.40487 8.15102 7.33464 8.32058 7.33464 8.4974C7.33464 8.67421 7.40487 8.84378 7.5299 8.9688C7.65492 9.09382 7.82449 9.16406 8.0013 9.16406C8.17811 9.16406 8.34768 9.09382 8.47271 8.9688C8.59773 8.84378 8.66797 8.67421 8.66797 8.4974C8.66797 8.32058 8.59773 8.15102 8.47271 8.02599C8.34768 7.90097 8.17811 7.83073 8.0013 7.83073ZM8.0013 12.4974C7.82449 12.4974 7.65492 12.5676 7.5299 12.6927C7.40487 12.8177 7.33464 12.9873 7.33464 13.1641C7.33464 13.3409 7.40487 13.5104 7.5299 13.6355C7.65492 13.7605 7.82449 13.8307 8.0013 13.8307C8.17811 13.8307 8.34768 13.7605 8.47271 13.6355C8.59773 13.5104 8.66797 13.3409 8.66797 13.1641C8.66797 12.9873 8.59773 12.8177 8.47271 12.6927C8.34768 12.5676 8.17811 12.4974 8.0013 12.4974Z" fill="white" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
492
src/app/ApiKeyHeader.js
Normal file
@ -0,0 +1,492 @@
|
||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class ApiKeyHeader extends LitElement {
|
||||
static properties = {
|
||||
apiKey: { type: String },
|
||||
isLoading: { type: Boolean },
|
||||
errorMessage: { type: String },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
transition: opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
:host(.sliding-out) {
|
||||
animation: slideOutUp 0.3s ease-in forwards;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
:host(.hidden) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideOutUp {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 285px;
|
||||
height: 220px;
|
||||
padding: 18px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 16px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 10;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.close-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 500; /* Medium */
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
flex-shrink: 0; /* 제목이 줄어들지 않도록 고정 */
|
||||
}
|
||||
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: auto; /* 이 속성이 제목과 폼 사이의 공간을 만듭니다. */
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: rgba(239, 68, 68, 0.9);
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
height: 14px; /* Reserve space to prevent layout shift */
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.api-input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
padding: 0 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 400; /* Regular */
|
||||
margin-bottom: 6px;
|
||||
text-align: center;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.api-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.api-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500; /* Medium */
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 10px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.or-text {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
font-weight: 500; /* Medium */
|
||||
margin: 10px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.dragState = null;
|
||||
this.wasJustDragged = false;
|
||||
this.apiKey = '';
|
||||
this.isLoading = false;
|
||||
this.errorMessage = '';
|
||||
this.validatedApiKey = null;
|
||||
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleInput = this.handleInput.bind(this);
|
||||
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
|
||||
this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.apiKey = '';
|
||||
this.isLoading = false;
|
||||
this.errorMessage = '';
|
||||
this.validatedApiKey = null;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
async handleMouseDown(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
const initialPosition = await ipcRenderer.invoke('get-header-position');
|
||||
|
||||
this.dragState = {
|
||||
initialMouseX: e.screenX,
|
||||
initialMouseY: e.screenY,
|
||||
initialWindowX: initialPosition.x,
|
||||
initialWindowY: initialPosition.y,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', this.handleMouseMove);
|
||||
window.addEventListener('mouseup', this.handleMouseUp, { once: true });
|
||||
}
|
||||
|
||||
handleMouseMove(e) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const deltaX = Math.abs(e.screenX - this.dragState.initialMouseX);
|
||||
const deltaY = Math.abs(e.screenY - this.dragState.initialMouseY);
|
||||
|
||||
if (deltaX > 3 || deltaY > 3) {
|
||||
this.dragState.moved = true;
|
||||
}
|
||||
|
||||
const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX);
|
||||
const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY);
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.invoke('move-header-to', newWindowX, newWindowY);
|
||||
}
|
||||
|
||||
handleMouseUp(e) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const wasDragged = this.dragState.moved;
|
||||
|
||||
window.removeEventListener('mousemove', this.handleMouseMove);
|
||||
this.dragState = null;
|
||||
|
||||
if (wasDragged) {
|
||||
this.wasJustDragged = true;
|
||||
setTimeout(() => {
|
||||
this.wasJustDragged = false;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
this.apiKey = e.target.value;
|
||||
this.errorMessage = '';
|
||||
console.log('Input changed:', this.apiKey?.length || 0, 'chars');
|
||||
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
const inputField = this.shadowRoot?.querySelector('.apikey-input');
|
||||
if (inputField && this.isInputFocused) {
|
||||
inputField.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handlePaste(e) {
|
||||
e.preventDefault();
|
||||
this.errorMessage = '';
|
||||
const clipboardText = (e.clipboardData || window.clipboardData).getData('text');
|
||||
console.log('Paste event detected:', clipboardText?.substring(0, 10) + '...');
|
||||
|
||||
if (clipboardText) {
|
||||
this.apiKey = clipboardText.trim();
|
||||
|
||||
const inputElement = e.target;
|
||||
inputElement.value = this.apiKey;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
const inputField = this.shadowRoot?.querySelector('.apikey-input');
|
||||
if (inputField) {
|
||||
inputField.focus();
|
||||
inputField.setSelectionRange(inputField.value.length, inputField.value.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleKeyPress(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit() {
|
||||
if (this.wasJustDragged || this.isLoading || !this.apiKey.trim()) {
|
||||
console.log('Submit blocked:', {
|
||||
wasJustDragged: this.wasJustDragged,
|
||||
isLoading: this.isLoading,
|
||||
hasApiKey: !!this.apiKey.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting API key validation...');
|
||||
this.isLoading = true;
|
||||
this.errorMessage = '';
|
||||
this.requestUpdate();
|
||||
|
||||
const apiKey = this.apiKey.trim();
|
||||
let isValid = false;
|
||||
try {
|
||||
const isValid = await this.validateApiKey(this.apiKey.trim());
|
||||
|
||||
if (isValid) {
|
||||
console.log('API key valid - starting slide out animation');
|
||||
this.startSlideOutAnimation();
|
||||
this.validatedApiKey = this.apiKey.trim();
|
||||
} else {
|
||||
this.errorMessage = 'Invalid API key - please check and try again';
|
||||
console.log('API key validation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API key validation error:', error);
|
||||
this.errorMessage = 'Validation error - please try again';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async validateApiKey(apiKey) {
|
||||
if (!apiKey || apiKey.length < 15) return false;
|
||||
if (!apiKey.match(/^[A-Za-z0-9_-]+$/)) return false;
|
||||
|
||||
try {
|
||||
console.log('Validating API key with openai models endpoint...');
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
const hasGPTModels = data.data && data.data.some(m => m.id.startsWith('gpt-'));
|
||||
if (hasGPTModels) {
|
||||
console.log('API key validation successful - GPT models available');
|
||||
return true;
|
||||
} else {
|
||||
console.log('API key valid but no GPT models available');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.log('API key validation failed:', response.status, errorData.error?.message || 'Unknown error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API key validation network error:', error);
|
||||
return apiKey.length >= 20; // Fallback for network issues
|
||||
}
|
||||
}
|
||||
|
||||
startSlideOutAnimation() {
|
||||
this.classList.add('sliding-out');
|
||||
}
|
||||
|
||||
handleUsePicklesKey(e) {
|
||||
e.preventDefault();
|
||||
if (this.wasJustDragged) return;
|
||||
|
||||
console.log('Requesting Firebase authentication from main process...');
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('start-firebase-auth');
|
||||
}
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
console.log('Close button clicked');
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('quit-application');
|
||||
}
|
||||
}
|
||||
|
||||
handleAnimationEnd(e) {
|
||||
if (e.target !== this) return;
|
||||
|
||||
if (this.classList.contains('sliding-out')) {
|
||||
this.classList.remove('sliding-out');
|
||||
this.classList.add('hidden');
|
||||
|
||||
if (this.validatedApiKey) {
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('api-key-validated', this.validatedApiKey);
|
||||
}
|
||||
this.validatedApiKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationend', this.handleAnimationEnd);
|
||||
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationend', this.handleAnimationEnd);
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
const isButtonDisabled = this.isLoading || !this.apiKey || !this.apiKey.trim();
|
||||
|
||||
return html`
|
||||
<div class="container" @mousedown=${this.handleMouseDown}>
|
||||
<button class="close-button" @click=${this.handleClose} title="Close application">
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="title">Choose how to power your AI</h1>
|
||||
|
||||
<div class="form-content">
|
||||
<div class="error-message">${this.errorMessage}</div>
|
||||
<input
|
||||
type="password"
|
||||
class="api-input"
|
||||
placeholder="Enter your OpenAI API key"
|
||||
.value=${this.apiKey || ''}
|
||||
@input=${this.handleInput}
|
||||
@keypress=${this.handleKeyPress}
|
||||
@paste=${this.handlePaste}
|
||||
@focus=${() => (this.errorMessage = '')}
|
||||
?disabled=${this.isLoading}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
tabindex="0"
|
||||
/>
|
||||
|
||||
<button class="action-button" @click=${this.handleSubmit} ?disabled=${isButtonDisabled} tabindex="0">
|
||||
${this.isLoading ? 'Validating...' : 'Confirm'}
|
||||
</button>
|
||||
|
||||
<div class="or-text">or</div>
|
||||
|
||||
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's API Key</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('apikey-header', ApiKeyHeader);
|
592
src/app/AppHeader.js
Normal file
@ -0,0 +1,592 @@
|
||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class AppHeader extends LitElement {
|
||||
static properties = {
|
||||
isSessionActive: { type: Boolean, state: true },
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
:host(.hiding) {
|
||||
animation: slideUp 0.45s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
|
||||
}
|
||||
|
||||
:host(.showing) {
|
||||
animation: slideDown 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
|
||||
}
|
||||
|
||||
:host(.sliding-in) {
|
||||
animation: fadeIn 0.25s ease-out forwards;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
:host(.hidden) {
|
||||
opacity: 0;
|
||||
transform: translateY(-180%) scale(0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0px) brightness(1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-20%) scale(0.96);
|
||||
filter: blur(0px) brightness(0.95);
|
||||
box-shadow: 0 6px 28px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: translateY(-60%) scale(0.9);
|
||||
filter: blur(1px) brightness(0.85);
|
||||
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.15;
|
||||
transform: translateY(-120%) scale(0.85);
|
||||
filter: blur(2px) brightness(0.75);
|
||||
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-180%) scale(0.8);
|
||||
filter: blur(3px) brightness(0.7);
|
||||
box-shadow: 0 0px 0px rgba(0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-180%) scale(0.8);
|
||||
filter: blur(3px) brightness(0.7);
|
||||
box-shadow: 0 0px 0px rgba(0, 0, 0, 0);
|
||||
}
|
||||
40% {
|
||||
opacity: 0.6;
|
||||
transform: translateY(-30%) scale(0.95);
|
||||
filter: blur(1px) brightness(0.9);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-5%) scale(1.01);
|
||||
filter: blur(0.3px) brightness(1.02);
|
||||
box-shadow: 0 7px 28px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
85% {
|
||||
opacity: 0.98;
|
||||
transform: translateY(1%) scale(0.995);
|
||||
filter: blur(0.1px) brightness(1.01);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.31);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0px) brightness(1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
height: 47px;
|
||||
padding: 2px 10px 2px 13px;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
border-radius: 9000px;
|
||||
/* backdrop-filter: blur(1px); */
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 9000px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.header::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
border-radius: 9000px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(169deg, rgba(255, 255, 255, 0.17) 0%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.17) 100%);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.listen-button {
|
||||
height: 26px;
|
||||
padding: 0 13px;
|
||||
background: transparent;
|
||||
border-radius: 9000px;
|
||||
justify-content: center;
|
||||
width: 78px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listen-button.active::before {
|
||||
background: rgba(215, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.listen-button.active:hover::before {
|
||||
background: rgba(255, 20, 20, 0.6);
|
||||
}
|
||||
|
||||
.listen-button:hover::before {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.listen-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border-radius: 9000px;
|
||||
z-index: -1;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.listen-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
border-radius: 9000px;
|
||||
padding: 1px;
|
||||
background: linear-gradient(169deg, rgba(255, 255, 255, 0.17) 0%, rgba(255, 255, 255, 0.08) 50%, rgba(255, 255, 255, 0.17) 100%);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: destination-out;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
height: 26px;
|
||||
box-sizing: border-box;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
display: flex;
|
||||
padding: 0 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.header-actions:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.ask-action {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.action-button,
|
||||
.settings-button {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
padding-bottom: 1px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.action-text-content {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
font-weight: 500; /* Medium */
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.icon-container.ask-icons svg,
|
||||
.icon-container.showhide-icons svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.listen-icon svg {
|
||||
width: 12px;
|
||||
height: 11px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
font-weight: 500;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 13%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
padding: 5px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.settings-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.dragState = null;
|
||||
this.wasJustDragged = false;
|
||||
this.isVisible = true;
|
||||
this.isAnimating = false;
|
||||
this.hasSlidIn = false;
|
||||
this.settingsHideTimer = null;
|
||||
this.isSessionActive = false;
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
ipcRenderer.on('toggle-header-visibility', () => {
|
||||
this.toggleVisibility();
|
||||
});
|
||||
|
||||
ipcRenderer.on('cancel-hide-settings', () => {
|
||||
this.cancelHideWindow('settings');
|
||||
});
|
||||
}
|
||||
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
|
||||
}
|
||||
|
||||
async handleMouseDown(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
const initialPosition = await ipcRenderer.invoke('get-header-position');
|
||||
|
||||
this.dragState = {
|
||||
initialMouseX: e.screenX,
|
||||
initialMouseY: e.screenY,
|
||||
initialWindowX: initialPosition.x,
|
||||
initialWindowY: initialPosition.y,
|
||||
moved: false,
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', this.handleMouseMove, { capture: true });
|
||||
window.addEventListener('mouseup', this.handleMouseUp, { once: true, capture: true });
|
||||
}
|
||||
|
||||
handleMouseMove(e) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const deltaX = Math.abs(e.screenX - this.dragState.initialMouseX);
|
||||
const deltaY = Math.abs(e.screenY - this.dragState.initialMouseY);
|
||||
|
||||
if (deltaX > 3 || deltaY > 3) {
|
||||
this.dragState.moved = true;
|
||||
}
|
||||
|
||||
const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX);
|
||||
const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY);
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.invoke('move-header-to', newWindowX, newWindowY);
|
||||
}
|
||||
|
||||
handleMouseUp(e) {
|
||||
if (!this.dragState) return;
|
||||
|
||||
const wasDragged = this.dragState.moved;
|
||||
|
||||
window.removeEventListener('mousemove', this.handleMouseMove, { capture: true });
|
||||
this.dragState = null;
|
||||
|
||||
if (wasDragged) {
|
||||
this.wasJustDragged = true;
|
||||
setTimeout(() => {
|
||||
this.wasJustDragged = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
toggleVisibility() {
|
||||
if (this.isAnimating) return;
|
||||
|
||||
this.isAnimating = true;
|
||||
|
||||
if (this.isVisible) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.show();
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.classList.remove('showing', 'hidden');
|
||||
this.classList.add('hiding');
|
||||
this.isVisible = false;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.classList.remove('hiding', 'hidden');
|
||||
this.classList.add('showing');
|
||||
this.isVisible = true;
|
||||
}
|
||||
|
||||
handleAnimationEnd(e) {
|
||||
if (e.target !== this) return;
|
||||
|
||||
this.isAnimating = false;
|
||||
|
||||
if (this.classList.contains('hiding')) {
|
||||
this.classList.remove('hiding');
|
||||
this.classList.add('hidden');
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.send('header-animation-complete', 'hidden');
|
||||
}
|
||||
} else if (this.classList.contains('showing')) {
|
||||
this.classList.remove('showing');
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.send('header-animation-complete', 'visible');
|
||||
}
|
||||
} else if (this.classList.contains('sliding-in')) {
|
||||
this.classList.remove('sliding-in');
|
||||
this.hasSlidIn = true;
|
||||
console.log('AppHeader slide-in animation completed');
|
||||
}
|
||||
}
|
||||
|
||||
startSlideInAnimation() {
|
||||
if (this.hasSlidIn) return;
|
||||
this.classList.add('sliding-in');
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationend', this.handleAnimationEnd);
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
this._sessionStateListener = (event, { isActive }) => {
|
||||
this.isSessionActive = isActive;
|
||||
};
|
||||
ipcRenderer.on('session-state-changed', this._sessionStateListener);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationend', this.handleAnimationEnd);
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.removeAllListeners('toggle-header-visibility');
|
||||
ipcRenderer.removeAllListeners('cancel-hide-settings');
|
||||
if (this._sessionStateListener) {
|
||||
ipcRenderer.removeListener('session-state-changed', this._sessionStateListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invoke(channel, ...args) {
|
||||
if (this.wasJustDragged) {
|
||||
return;
|
||||
}
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
showWindow(name, element) {
|
||||
if (this.wasJustDragged) return;
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
console.log(`[AppHeader] showWindow('${name}') called at ${Date.now()}`);
|
||||
|
||||
ipcRenderer.send('cancel-hide-window', name);
|
||||
|
||||
if (name === 'settings' && element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
ipcRenderer.send('show-window', {
|
||||
name: 'settings',
|
||||
bounds: {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ipcRenderer.send('show-window', name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hideWindow(name) {
|
||||
if (this.wasJustDragged) return;
|
||||
if (window.require) {
|
||||
console.log(`[AppHeader] hideWindow('${name}') called at ${Date.now()}`);
|
||||
window.require('electron').ipcRenderer.send('hide-window', name);
|
||||
}
|
||||
}
|
||||
|
||||
cancelHideWindow(name) {
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="header" @mousedown=${this.handleMouseDown}>
|
||||
<button
|
||||
class="listen-button ${this.isSessionActive ? 'active' : ''}"
|
||||
@click=${() => this.invoke(this.isSessionActive ? 'close-session' : 'toggle-feature', 'listen')}
|
||||
>
|
||||
<div class="action-text">
|
||||
<div class="action-text-content">${this.isSessionActive ? 'Stop' : 'Listen'}</div>
|
||||
</div>
|
||||
<div class="listen-icon">
|
||||
${this.isSessionActive
|
||||
? html`
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="9" height="9" rx="1" fill="white"/>
|
||||
</svg>
|
||||
|
||||
`
|
||||
: html`
|
||||
<svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.69922 2.7515C1.69922 2.37153 2.00725 2.0635 2.38722 2.0635H2.73122C3.11119 2.0635 3.41922 2.37153 3.41922 2.7515V8.2555C3.41922 8.63547 3.11119 8.9435 2.73122 8.9435H2.38722C2.00725 8.9435 1.69922 8.63547 1.69922 8.2555V2.7515Z" fill="white"/>
|
||||
<path d="M5.13922 1.3755C5.13922 0.995528 5.44725 0.6875 5.82722 0.6875H6.17122C6.55119 0.6875 6.85922 0.995528 6.85922 1.3755V9.6315C6.85922 10.0115 6.55119 10.3195 6.17122 10.3195H5.82722C5.44725 10.3195 5.13922 10.0115 5.13922 9.6315V1.3755Z" fill="white"/>
|
||||
<path d="M8.57922 3.0955C8.57922 2.71553 8.88725 2.4075 9.26722 2.4075H9.61122C9.99119 2.4075 10.2992 2.71553 10.2992 3.0955V7.9115C10.2992 8.29147 9.99119 8.5995 9.61122 8.5995H9.26722C8.88725 8.5995 8.57922 8.29147 8.57922 7.9115V3.0955Z" fill="white"/>
|
||||
</svg>
|
||||
`}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="header-actions ask-action" @click=${() => this.invoke('toggle-feature', 'ask')}>
|
||||
<div class="action-text">
|
||||
<div class="action-text-content">Ask</div>
|
||||
</div>
|
||||
<div class="icon-container ask-icons">
|
||||
<div class="icon-box">⌘</div>
|
||||
<div class="icon-box">
|
||||
<svg viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.41797 8.16406C2.41797 8.00935 2.47943 7.86098 2.58882 7.75158C2.69822 7.64219 2.84659 7.58073 3.0013 7.58073H10.0013C10.4654 7.58073 10.9106 7.39636 11.2387 7.06817C11.5669 6.73998 11.7513 6.29486 11.7513 5.83073V3.4974C11.7513 3.34269 11.8128 3.19431 11.9222 3.08492C12.0316 2.97552 12.1799 2.91406 12.3346 2.91406C12.4893 2.91406 12.6377 2.97552 12.7471 3.08492C12.8565 3.19431 12.918 3.34269 12.918 3.4974V5.83073C12.918 6.60428 12.6107 7.34614 12.0637 7.89312C11.5167 8.44011 10.7748 8.7474 10.0013 8.7474H3.0013C2.84659 8.7474 2.69822 8.68594 2.58882 8.57654C2.47943 8.46715 2.41797 8.31877 2.41797 8.16406Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.58876 8.57973C2.4794 8.47034 2.41797 8.32199 2.41797 8.16731C2.41797 8.01263 2.4794 7.86429 2.58876 7.75489L4.92209 5.42156C5.03211 5.3153 5.17946 5.25651 5.33241 5.25783C5.48536 5.25916 5.63167 5.32051 5.73982 5.42867C5.84798 5.53682 5.90932 5.68313 5.91065 5.83608C5.91198 5.98903 5.85319 6.13638 5.74693 6.24639L3.82601 8.16731L5.74693 10.0882C5.80264 10.142 5.84708 10.2064 5.87765 10.2776C5.90823 10.3487 5.92432 10.4253 5.92499 10.5027C5.92566 10.5802 5.9109 10.657 5.88157 10.7287C5.85224 10.8004 5.80893 10.8655 5.75416 10.9203C5.69939 10.9751 5.63426 11.0184 5.56257 11.0477C5.49088 11.077 5.41406 11.0918 5.33661 11.0911C5.25916 11.0905 5.18261 11.0744 5.11144 11.0438C5.04027 11.0132 4.9759 10.9688 4.92209 10.9131L2.58876 8.57973Z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" @click=${() => this.invoke('toggle-all-windows-visibility')}>
|
||||
<div class="action-text">
|
||||
<div class="action-text-content">Show/Hide</div>
|
||||
</div>
|
||||
<div class="icon-container showhide-icons">
|
||||
<div class="icon-box">⌘</div>
|
||||
<div class="icon-box">
|
||||
<svg viewBox="0 0 6 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.50391 1.32812L5.16391 10.673" stroke="white" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="settings-button"
|
||||
@mouseenter=${(e) => this.showWindow('settings', e.currentTarget)}
|
||||
@mouseleave=${() => this.hideWindow('settings')}
|
||||
>
|
||||
<div class="settings-icon">
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.0013 3.16406C7.82449 3.16406 7.65492 3.2343 7.5299 3.35932C7.40487 3.48435 7.33464 3.65392 7.33464 3.83073C7.33464 4.00754 7.40487 4.17711 7.5299 4.30213C7.65492 4.42716 7.82449 4.4974 8.0013 4.4974C8.17811 4.4974 8.34768 4.42716 8.47271 4.30213C8.59773 4.17711 8.66797 4.00754 8.66797 3.83073C8.66797 3.65392 8.59773 3.48435 8.47271 3.35932C8.34768 3.2343 8.17811 3.16406 8.0013 3.16406ZM8.0013 7.83073C7.82449 7.83073 7.65492 7.90097 7.5299 8.02599C7.40487 8.15102 7.33464 8.32058 7.33464 8.4974C7.33464 8.67421 7.40487 8.84378 7.5299 8.9688C7.65492 9.09382 7.82449 9.16406 8.0013 9.16406C8.17811 9.16406 8.34768 9.09382 8.47271 8.9688C8.59773 8.84378 8.66797 8.67421 8.66797 8.4974C8.66797 8.32058 8.59773 8.15102 8.47271 8.02599C8.34768 7.90097 8.17811 7.83073 8.0013 7.83073ZM8.0013 12.4974C7.82449 12.4974 7.65492 12.5676 7.5299 12.6927C7.40487 12.8177 7.33464 12.9873 7.33464 13.1641C7.33464 13.3409 7.40487 13.5104 7.5299 13.6355C7.65492 13.7605 7.82449 13.8307 8.0013 13.8307C8.17811 13.8307 8.34768 13.7605 8.47271 13.6355C8.59773 13.5104 8.66797 13.3409 8.66797 13.1641C8.66797 12.9873 8.59773 12.8177 8.47271 12.6927C8.34768 12.5676 8.17811 12.4974 8.0013 12.4974Z" fill="white" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('app-header', AppHeader);
|
273
src/app/HeaderController.js
Normal file
@ -0,0 +1,273 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, onAuthStateChanged, GoogleAuthProvider, signInWithCredential, signInWithCustomToken, signOut } from 'firebase/auth';
|
||||
|
||||
import './AppHeader.js';
|
||||
import './ApiKeyHeader.js';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g',
|
||||
authDomain: 'pickle-3651a.firebaseapp.com',
|
||||
projectId: 'pickle-3651a',
|
||||
storageBucket: 'pickle-3651a.firebasestorage.app',
|
||||
messagingSenderId: '904706892885',
|
||||
appId: '1:904706892885:web:0e42b3dda796674ead20dc',
|
||||
measurementId: 'G-SQ0WM6S28T',
|
||||
};
|
||||
|
||||
const firebaseApp = initializeApp(firebaseConfig);
|
||||
const auth = getAuth(firebaseApp);
|
||||
|
||||
class HeaderTransitionManager {
|
||||
constructor() {
|
||||
|
||||
this.headerContainer = document.getElementById('header-container');
|
||||
this.currentHeaderType = null; // 'apikey' | 'app'
|
||||
this.apiKeyHeader = null;
|
||||
this.appHeader = null;
|
||||
|
||||
/**
|
||||
* only one header window is allowed
|
||||
* @param {'apikey'|'app'} type
|
||||
*/
|
||||
this.ensureHeader = (type) => {
|
||||
if (this.currentHeaderType === type) return;
|
||||
|
||||
if (this.apiKeyHeader) { this.apiKeyHeader.remove(); this.apiKeyHeader = null; }
|
||||
if (this.appHeader) { this.appHeader.remove(); this.appHeader = null; }
|
||||
|
||||
if (type === 'apikey') {
|
||||
this.apiKeyHeader = document.createElement('apikey-header');
|
||||
this.headerContainer.appendChild(this.apiKeyHeader);
|
||||
} else {
|
||||
this.appHeader = document.createElement('app-header');
|
||||
this.headerContainer.appendChild(this.appHeader);
|
||||
this.appHeader.startSlideInAnimation?.();
|
||||
}
|
||||
|
||||
this.currentHeaderType = type;
|
||||
this.notifyHeaderState(type);
|
||||
};
|
||||
|
||||
console.log('[HeaderController] Manager initialized');
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer
|
||||
.invoke('get-current-api-key')
|
||||
.then(storedKey => {
|
||||
this.hasApiKey = !!storedKey;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
ipcRenderer.on('login-successful', async (event, payload) => {
|
||||
const { customToken, token, error } = payload || {};
|
||||
try {
|
||||
if (customToken) {
|
||||
console.log('[HeaderController] Received custom token, signing in with custom token...');
|
||||
await signInWithCustomToken(auth, customToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
console.log('[HeaderController] Received ID token, attempting Google credential sign-in...');
|
||||
const credential = GoogleAuthProvider.credential(token);
|
||||
await signInWithCredential(auth, credential);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.warn('[HeaderController] Login payload indicates verification failure. Proceeding to AppHeader UI only.');
|
||||
this.transitionToAppHeader();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Sign-in failed', error);
|
||||
this.transitionToAppHeader();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
ipcRenderer.on('request-firebase-logout', async () => {
|
||||
console.log('[HeaderController] Received request to sign out.');
|
||||
try {
|
||||
await signOut(auth);
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Sign out failed', error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('api-key-validated', () => {
|
||||
this.hasApiKey = true;
|
||||
this.transitionToAppHeader();
|
||||
});
|
||||
|
||||
ipcRenderer.on('api-key-removed', () => {
|
||||
this.hasApiKey = false;
|
||||
this.transitionToApiKeyHeader();
|
||||
});
|
||||
|
||||
ipcRenderer.on('api-key-updated', () => {
|
||||
this.hasApiKey = true;
|
||||
if (!auth.currentUser) {
|
||||
this.transitionToAppHeader();
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('firebase-auth-success', async (event, firebaseUser) => {
|
||||
console.log('[HeaderController] Received firebase-auth-success:', firebaseUser.uid);
|
||||
try {
|
||||
if (firebaseUser.idToken) {
|
||||
const credential = GoogleAuthProvider.credential(firebaseUser.idToken);
|
||||
await signInWithCredential(auth, credential);
|
||||
console.log('[HeaderController] Firebase sign-in successful via ID token');
|
||||
} else {
|
||||
console.warn('[HeaderController] No ID token received from deeplink, virtual key request may fail');
|
||||
this.transitionToAppHeader();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Firebase auth failed:', error);
|
||||
this.transitionToAppHeader();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._bootstrap();
|
||||
|
||||
onAuthStateChanged(auth, async user => {
|
||||
console.log('[HeaderController] Auth state changed. User:', user ? user.email : 'null');
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
let userDataWithToken = null;
|
||||
if (user) {
|
||||
try {
|
||||
const idToken = await user.getIdToken();
|
||||
userDataWithToken = {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
photoURL: user.photoURL,
|
||||
idToken: idToken,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Failed to get ID token:', error);
|
||||
userDataWithToken = {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
photoURL: user.photoURL,
|
||||
idToken: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.invoke('firebase-auth-state-changed', userDataWithToken).catch(console.error);
|
||||
}
|
||||
|
||||
if (!this.isInitialized) {
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
console.log('[HeaderController] User is logged in, transitioning to AppHeader');
|
||||
this.transitionToAppHeader(!this.hasApiKey);
|
||||
} else if (this.hasApiKey) {
|
||||
console.log('[HeaderController] No Firebase user but API key exists, showing AppHeader');
|
||||
this.transitionToAppHeader(false);
|
||||
} else {
|
||||
console.log('[HeaderController] No auth & no API key — showing ApiKeyHeader');
|
||||
this.transitionToApiKeyHeader();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
notifyHeaderState(stateOverride) {
|
||||
const state = stateOverride || this.currentHeaderType || 'apikey';
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.send('header-state-changed', state);
|
||||
}
|
||||
}
|
||||
|
||||
async _bootstrap() {
|
||||
let storedKey = null;
|
||||
if (window.require) {
|
||||
try {
|
||||
storedKey = await window
|
||||
.require('electron')
|
||||
.ipcRenderer.invoke('get-current-api-key');
|
||||
} catch (_) {}
|
||||
}
|
||||
this.hasApiKey = !!storedKey;
|
||||
|
||||
const user = await new Promise(resolve => {
|
||||
const unsubscribe = onAuthStateChanged(auth, u => {
|
||||
unsubscribe();
|
||||
resolve(u);
|
||||
});
|
||||
});
|
||||
|
||||
if (user || this.hasApiKey) {
|
||||
await this._resizeForApp();
|
||||
this.ensureHeader('app');
|
||||
} else {
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async transitionToAppHeader(animate = true) {
|
||||
if (this.currentHeaderType === 'app') {
|
||||
return this._resizeForApp();
|
||||
}
|
||||
|
||||
const canAnimate =
|
||||
animate &&
|
||||
this.apiKeyHeader &&
|
||||
!this.apiKeyHeader.classList.contains('hidden') &&
|
||||
typeof this.apiKeyHeader.startSlideOutAnimation === 'function';
|
||||
|
||||
if (canAnimate) {
|
||||
const old = this.apiKeyHeader;
|
||||
const onEnd = () => {
|
||||
clearTimeout(fallback);
|
||||
this._resizeForApp().then(() => this.ensureHeader('app'));
|
||||
};
|
||||
old.addEventListener('animationend', onEnd, { once: true });
|
||||
old.startSlideOutAnimation();
|
||||
|
||||
const fallback = setTimeout(onEnd, 450);
|
||||
} else {
|
||||
this.ensureHeader('app');
|
||||
this._resizeForApp();
|
||||
}
|
||||
}
|
||||
|
||||
_resizeForApp() {
|
||||
if (!window.require) return;
|
||||
return window
|
||||
.require('electron')
|
||||
.ipcRenderer.invoke('resize-header-window', { width: 353, height: 60 })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async transitionToApiKeyHeader() {
|
||||
await window.require('electron')
|
||||
.ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 });
|
||||
|
||||
if (this.currentHeaderType !== 'apikey') {
|
||||
this.ensureHeader('apikey');
|
||||
}
|
||||
|
||||
if (this.apiKeyHeader) this.apiKeyHeader.reset();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
new HeaderTransitionManager();
|
||||
});
|
285
src/app/PickleGlassApp.js
Normal file
@ -0,0 +1,285 @@
|
||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
import { CustomizeView } from '../features/customize/CustomizeView.js';
|
||||
import { AssistantView } from '../features/listen/AssistantView.js';
|
||||
import { OnboardingView } from '../features/onboarding/OnboardingView.js';
|
||||
import { AskView } from '../features/ask/AskView.js';
|
||||
|
||||
import '../features/listen/renderer.js';
|
||||
|
||||
export class PickleGlassApp extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
assistant-view {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ask-view, customize-view, history-view, help-view, onboarding-view, setup-view {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
currentView: { type: String },
|
||||
statusText: { type: String },
|
||||
startTime: { type: Number },
|
||||
currentResponseIndex: { type: Number },
|
||||
isMainViewVisible: { type: Boolean },
|
||||
selectedProfile: { type: String },
|
||||
selectedLanguage: { type: String },
|
||||
selectedScreenshotInterval: { type: String },
|
||||
selectedImageQuality: { type: String },
|
||||
isClickThrough: { type: Boolean, state: true },
|
||||
layoutMode: { type: String },
|
||||
_viewInstances: { type: Object, state: true },
|
||||
_isClickThrough: { state: true },
|
||||
structuredData: { type: Object },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.currentView = urlParams.get('view') || 'listen';
|
||||
this.currentResponseIndex = -1;
|
||||
this.selectedProfile = localStorage.getItem('selectedProfile') || 'interview';
|
||||
this.selectedLanguage = localStorage.getItem('selectedLanguage') || 'en-US';
|
||||
this.selectedScreenshotInterval = localStorage.getItem('selectedScreenshotInterval') || '5';
|
||||
this.selectedImageQuality = localStorage.getItem('selectedImageQuality') || 'medium';
|
||||
this._isClickThrough = false;
|
||||
this.outlines = [];
|
||||
this.analysisRequests = [];
|
||||
|
||||
window.pickleGlass.setStructuredData = data => {
|
||||
this.updateStructuredData(data);
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
ipcRenderer.on('update-status', (_, status) => this.setStatus(status));
|
||||
ipcRenderer.on('click-through-toggled', (_, isEnabled) => {
|
||||
this._isClickThrough = isEnabled;
|
||||
});
|
||||
ipcRenderer.on('show-view', (_, view) => {
|
||||
this.currentView = view;
|
||||
this.isMainViewVisible = true;
|
||||
});
|
||||
ipcRenderer.on('start-listening-session', () => {
|
||||
console.log('Received start-listening-session command, calling handleListenClick.');
|
||||
this.handleListenClick();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.removeAllListeners('update-status');
|
||||
ipcRenderer.removeAllListeners('click-through-toggled');
|
||||
ipcRenderer.removeAllListeners('show-view');
|
||||
ipcRenderer.removeAllListeners('start-listening-session');
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
if (changedProperties.has('isMainViewVisible') || changedProperties.has('currentView')) {
|
||||
this.requestWindowResize();
|
||||
}
|
||||
|
||||
if (changedProperties.has('currentView') && window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.send('view-changed', this.currentView);
|
||||
|
||||
const viewContainer = this.shadowRoot?.querySelector('.view-container');
|
||||
if (viewContainer) {
|
||||
viewContainer.classList.add('entering');
|
||||
requestAnimationFrame(() => {
|
||||
viewContainer.classList.remove('entering');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only update localStorage when these specific properties change
|
||||
if (changedProperties.has('selectedProfile')) {
|
||||
localStorage.setItem('selectedProfile', this.selectedProfile);
|
||||
}
|
||||
if (changedProperties.has('selectedLanguage')) {
|
||||
localStorage.setItem('selectedLanguage', this.selectedLanguage);
|
||||
}
|
||||
if (changedProperties.has('selectedScreenshotInterval')) {
|
||||
localStorage.setItem('selectedScreenshotInterval', this.selectedScreenshotInterval);
|
||||
}
|
||||
if (changedProperties.has('selectedImageQuality')) {
|
||||
localStorage.setItem('selectedImageQuality', this.selectedImageQuality);
|
||||
}
|
||||
if (changedProperties.has('layoutMode')) {
|
||||
this.updateLayoutMode();
|
||||
}
|
||||
}
|
||||
|
||||
requestWindowResize() {
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.invoke('resize-window', {
|
||||
isMainViewVisible: this.isMainViewVisible,
|
||||
view: this.currentView,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(text) {
|
||||
this.statusText = text;
|
||||
}
|
||||
|
||||
async handleListenClick() {
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
const isActive = await ipcRenderer.invoke('is-session-active');
|
||||
if (isActive) {
|
||||
console.log('Session is already active. No action needed.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (window.pickleGlass) {
|
||||
await window.pickleGlass.initializeopenai(this.selectedProfile, this.selectedLanguage);
|
||||
window.pickleGlass.startCapture(this.selectedScreenshotInterval, this.selectedImageQuality);
|
||||
}
|
||||
|
||||
// 🔄 Clear previous summary/analysis when a new listening session begins
|
||||
this.structuredData = {
|
||||
summary: [],
|
||||
topic: { header: '', bullets: [] },
|
||||
actions: [],
|
||||
followUps: [],
|
||||
};
|
||||
|
||||
this.currentResponseIndex = -1;
|
||||
this.startTime = Date.now();
|
||||
this.currentView = 'listen';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
handleShowHideClick() {
|
||||
this.isMainViewVisible = !this.isMainViewVisible;
|
||||
}
|
||||
|
||||
handleCustomizeClick() {
|
||||
this.currentView = 'customize';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
handleHelpClick() {
|
||||
this.currentView = 'help';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
handleHistoryClick() {
|
||||
this.currentView = 'history';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
async handleClose() {
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
await ipcRenderer.invoke('quit-application');
|
||||
}
|
||||
}
|
||||
|
||||
handleBackClick() {
|
||||
this.currentView = 'listen';
|
||||
}
|
||||
|
||||
async handleSendText(message) {
|
||||
if (window.pickleGlass) {
|
||||
const result = await window.pickleGlass.sendTextMessage(message);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to send message:', result.error);
|
||||
this.setStatus('Error sending message: ' + result.error);
|
||||
} else {
|
||||
this.setStatus('Message sent...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateOutline(outline) {
|
||||
// console.log('📝 PickleGlassApp updateOutline:', outline);
|
||||
// this.outlines = [...outline];
|
||||
// this.requestUpdate();
|
||||
// }
|
||||
|
||||
// updateAnalysisRequests(requests) {
|
||||
// console.log('📝 PickleGlassApp updateAnalysisRequests:', requests);
|
||||
// this.analysisRequests = [...requests];
|
||||
// this.requestUpdate();
|
||||
// }
|
||||
|
||||
updateStructuredData(data) {
|
||||
console.log('📝 PickleGlassApp updateStructuredData:', data);
|
||||
this.structuredData = data;
|
||||
this.requestUpdate();
|
||||
|
||||
const assistantView = this.shadowRoot?.querySelector('assistant-view');
|
||||
if (assistantView) {
|
||||
assistantView.structuredData = data;
|
||||
console.log('✅ Structured data passed to AssistantView');
|
||||
}
|
||||
}
|
||||
|
||||
handleResponseIndexChanged(e) {
|
||||
this.currentResponseIndex = e.detail.index;
|
||||
}
|
||||
|
||||
handleOnboardingComplete() {
|
||||
this.currentView = 'main';
|
||||
}
|
||||
|
||||
render() {
|
||||
switch (this.currentView) {
|
||||
case 'listen':
|
||||
return html`<assistant-view
|
||||
.currentResponseIndex=${this.currentResponseIndex}
|
||||
.selectedProfile=${this.selectedProfile}
|
||||
.structuredData=${this.structuredData}
|
||||
.onSendText=${message => this.handleSendText(message)}
|
||||
@response-index-changed=${e => (this.currentResponseIndex = e.detail.index)}
|
||||
></assistant-view>`;
|
||||
case 'ask':
|
||||
return html`<ask-view></ask-view>`;
|
||||
case 'customize':
|
||||
return html`<customize-view
|
||||
.selectedProfile=${this.selectedProfile}
|
||||
.selectedLanguage=${this.selectedLanguage}
|
||||
.onProfileChange=${profile => (this.selectedProfile = profile)}
|
||||
.onLanguageChange=${lang => (this.selectedLanguage = lang)}
|
||||
></customize-view>`;
|
||||
case 'history':
|
||||
return html`<history-view></history-view>`;
|
||||
case 'help':
|
||||
return html`<help-view></help-view>`;
|
||||
case 'onboarding':
|
||||
return html`<onboarding-view></onboarding-view>`;
|
||||
case 'setup':
|
||||
return html`<setup-view></setup-view>`;
|
||||
default:
|
||||
return html`<div>Unknown view: ${this.currentView}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('pickle-glass-app', PickleGlassApp);
|
316
src/app/content.html
Normal file
@ -0,0 +1,316 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" />
|
||||
<title>Pickle Glass Content</title>
|
||||
<style>
|
||||
:root {
|
||||
--background-transparent: transparent;
|
||||
--text-color: #e5e5e7;
|
||||
--border-color: rgba(255, 255, 255, 0.2);
|
||||
--header-background: rgba(0, 0, 0, 0.8);
|
||||
--header-actions-color: rgba(255, 255, 255, 0.6);
|
||||
--main-content-background: rgba(0, 0, 0, 0.8);
|
||||
--button-background: rgba(0, 0, 0, 0.5);
|
||||
--button-border: rgba(255, 255, 255, 0.1);
|
||||
--icon-button-color: rgb(229, 229, 231);
|
||||
--hover-background: rgba(255, 255, 255, 0.1);
|
||||
--input-background: rgba(0, 0, 0, 0.3);
|
||||
--placeholder-color: rgba(255, 255, 255, 0.4);
|
||||
--focus-border-color: #007aff;
|
||||
--focus-box-shadow: rgba(0, 122, 255, 0.2);
|
||||
--input-focus-background: rgba(0, 0, 0, 0.5);
|
||||
--scrollbar-track: rgba(0, 0, 0, 0.2);
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.2);
|
||||
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
|
||||
--preview-video-background: rgba(0, 0, 0, 0.9);
|
||||
--preview-video-border: rgba(255, 255, 255, 0.15);
|
||||
--option-label-color: rgba(255, 255, 255, 0.8);
|
||||
--screen-option-background: rgba(0, 0, 0, 0.4);
|
||||
--screen-option-hover-background: rgba(0, 0, 0, 0.6);
|
||||
--screen-option-selected-background: rgba(0, 122, 255, 0.15);
|
||||
--screen-option-text: rgba(255, 255, 255, 0.7);
|
||||
--description-color: rgba(255, 255, 255, 0.6);
|
||||
--start-button-background: white;
|
||||
--start-button-color: black;
|
||||
--start-button-border: white;
|
||||
--start-button-hover-background: rgba(255, 255, 255, 0.8);
|
||||
--start-button-hover-border: rgba(0, 0, 0, 0.2);
|
||||
--text-input-button-background: #007aff;
|
||||
--text-input-button-hover: #0056b3;
|
||||
--link-color: #007aff;
|
||||
--key-background: rgba(255, 255, 255, 0.1);
|
||||
--scrollbar-background: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Layout-specific variables */
|
||||
--header-padding: 10px 20px;
|
||||
--header-font-size: 16px;
|
||||
--header-gap: 12px;
|
||||
--header-button-padding: 8px 16px;
|
||||
--header-icon-padding: 8px;
|
||||
--header-font-size-small: 13px;
|
||||
--main-content-padding: 20px;
|
||||
--main-content-margin-top: 10px;
|
||||
--icon-size: 24px;
|
||||
--border-radius: 7px;
|
||||
--content-border-radius: 7px;
|
||||
}
|
||||
|
||||
/* Compact layout styles */
|
||||
:root.compact-layout {
|
||||
--header-padding: 6px 12px;
|
||||
--header-font-size: 13px;
|
||||
--header-gap: 6px;
|
||||
--header-button-padding: 4px 8px;
|
||||
--header-icon-padding: 4px;
|
||||
--header-font-size-small: 10px;
|
||||
--main-content-padding: 10px;
|
||||
--main-content-margin-top: 2px;
|
||||
--icon-size: 16px;
|
||||
--border-radius: 4px;
|
||||
--content-border-radius: 4px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
pickle-glass-app {
|
||||
display: block;
|
||||
width: 100%;
|
||||
transform: translate3d(0, 0, 0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
transform-origin: center center;
|
||||
contain: layout style paint;
|
||||
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
.window-sliding-down {
|
||||
animation: slideDownFromHeader 0.25s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.window-sliding-up {
|
||||
animation: slideUpToHeader 0.18s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.window-hidden {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
|
||||
pointer-events: none;
|
||||
will-change: auto;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.listen-window-moving {
|
||||
transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.listen-window-center {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.listen-window-left {
|
||||
transform: translate3d(-110px, 0, 0);
|
||||
}
|
||||
|
||||
@keyframes slideDownFromHeader {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.4;
|
||||
transform: translate3d(0, -10px, 0) scale3d(0.98, 0.98, 1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: translate3d(0, -3px, 0) scale3d(1.01, 1.01, 1);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.9;
|
||||
transform: translate3d(0, -0.5px, 0) scale3d(1.005, 1.005, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-window-show {
|
||||
animation: settingsPopFromButton 0.22s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
transform-origin: 85% 0%;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.settings-window-hide {
|
||||
animation: settingsCollapseToButton 0.18s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
|
||||
transform-origin: 85% 0%;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
@keyframes settingsPopFromButton {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
|
||||
}
|
||||
40% {
|
||||
opacity: 0.8;
|
||||
transform: translate3d(0, -2px, 0) scale3d(1.05, 1.05, 1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.95;
|
||||
transform: translate3d(0, 0, 0) scale3d(1.02, 1.02, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsCollapseToButton {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.8;
|
||||
transform: translate3d(0, -1px, 0) scale3d(0.9, 0.9, 1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: translate3d(0, -5px, 0) scale3d(0.7, 0.7, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUpToHeader {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
transform: translate3d(0, -6px, 0) scale3d(0.98, 0.98, 1);
|
||||
}
|
||||
65% {
|
||||
opacity: 0.2;
|
||||
transform: translate3d(0, -14px, 0) scale3d(0.95, 0.95, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -18px, 0) scale3d(0.93, 0.93, 1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="../assets/marked-4.3.0.min.js"></script>
|
||||
|
||||
<script type="module" src="../../public/build/content.js"></script>
|
||||
|
||||
<pickle-glass-app id="pickle-glass"></pickle-glass-app>
|
||||
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const app = document.getElementById('pickle-glass');
|
||||
let animationTimeout = null;
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
ipcRenderer.on('window-show-animation', () => {
|
||||
console.log('Starting window show animation');
|
||||
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
|
||||
app.classList.add('window-sliding-down');
|
||||
|
||||
if (animationTimeout) clearTimeout(animationTimeout);
|
||||
animationTimeout = setTimeout(() => {
|
||||
app.classList.remove('window-sliding-down');
|
||||
}, 250);
|
||||
});
|
||||
|
||||
ipcRenderer.on('settings-window-show-animation', () => {
|
||||
console.log('Starting settings window show animation');
|
||||
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
|
||||
app.classList.add('settings-window-show');
|
||||
|
||||
if (animationTimeout) clearTimeout(animationTimeout);
|
||||
animationTimeout = setTimeout(() => {
|
||||
app.classList.remove('settings-window-show');
|
||||
}, 220);
|
||||
});
|
||||
|
||||
ipcRenderer.on('window-hide-animation', () => {
|
||||
console.log('Starting window hide animation');
|
||||
app.classList.remove('window-sliding-down', 'settings-window-show');
|
||||
app.classList.add('window-sliding-up');
|
||||
|
||||
if (animationTimeout) clearTimeout(animationTimeout);
|
||||
animationTimeout = setTimeout(() => {
|
||||
app.classList.remove('window-sliding-up');
|
||||
app.classList.add('window-hidden');
|
||||
}, 180);
|
||||
});
|
||||
|
||||
ipcRenderer.on('settings-window-hide-animation', () => {
|
||||
console.log('Starting settings window hide animation');
|
||||
app.classList.remove('window-sliding-down', 'settings-window-show');
|
||||
app.classList.add('settings-window-hide');
|
||||
|
||||
if (animationTimeout) clearTimeout(animationTimeout);
|
||||
animationTimeout = setTimeout(() => {
|
||||
app.classList.remove('settings-window-hide');
|
||||
app.classList.add('window-hidden');
|
||||
}, 180);
|
||||
});
|
||||
|
||||
ipcRenderer.on('listen-window-move-to-center', () => {
|
||||
console.log('Moving listen window to center');
|
||||
app.classList.add('listen-window-moving');
|
||||
app.classList.remove('listen-window-left');
|
||||
app.classList.add('listen-window-center');
|
||||
|
||||
setTimeout(() => {
|
||||
app.classList.remove('listen-window-moving');
|
||||
}, 350);
|
||||
});
|
||||
|
||||
ipcRenderer.on('listen-window-move-to-left', () => {
|
||||
console.log('Moving listen window to left');
|
||||
app.classList.add('listen-window-moving');
|
||||
app.classList.remove('listen-window-center');
|
||||
app.classList.add('listen-window-left');
|
||||
|
||||
setTimeout(() => {
|
||||
app.classList.remove('listen-window-moving');
|
||||
}, 350);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
24
src/app/header.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" />
|
||||
<title>Pickle Glass Header</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header-container" tabindex="0" style="outline: none;">
|
||||
<!-- <apikey-header id="apikey-header" style="display: none;"></apikey-header>
|
||||
<app-header id="app-header" style="display: none;"></app-header> -->
|
||||
</div>
|
||||
|
||||
<script type="module" src="../../public/build/header.js"></script>
|
||||
</body>
|
||||
</html>
|
5
src/assets/Listen.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.699219 2.2515C0.699219 1.87153 1.00725 1.5635 1.38722 1.5635H1.73122C2.11119 1.5635 2.41922 1.87153 2.41922 2.2515V7.7555C2.41922 8.13547 2.11119 8.4435 1.73122 8.4435H1.38722C1.00725 8.4435 0.699219 8.13547 0.699219 7.7555V2.2515Z" fill="white"/>
|
||||
<path d="M4.13922 0.8755C4.13922 0.495528 4.44725 0.1875 4.82722 0.1875H5.17122C5.55119 0.1875 5.85922 0.495528 5.85922 0.8755V9.1315C5.85922 9.51147 5.55119 9.8195 5.17122 9.8195H4.82722C4.44725 9.8195 4.13922 9.51147 4.13922 9.1315V0.8755Z" fill="white"/>
|
||||
<path d="M7.57922 2.5955C7.57922 2.21553 7.88725 1.9075 8.26722 1.9075H8.61122C8.99119 1.9075 9.29922 2.21553 9.29922 2.5955V7.4115C9.29922 7.79147 8.99119 8.0995 8.61122 8.0995H8.26722C7.88725 8.0995 7.57922 7.79147 7.57922 7.4115V2.5955Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 877 B |
BIN
src/assets/SystemAudioDump
Executable file
3
src/assets/Vector.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="6" height="12" viewBox="0 0 6 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.50391 1.32812L5.16391 10.673" stroke="white" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 226 B |
3
src/assets/command.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="11" height="12" viewBox="0 0 11 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.41797 2.79167C0.41797 2.52355 0.470779 2.25806 0.573382 2.01036C0.675986 1.76265 0.826374 1.53758 1.01596 1.34799C1.20555 1.1584 1.43062 1.00802 1.67832 0.905414C1.92603 0.80281 2.19152 0.750001 2.45964 0.750001C2.72775 0.750001 2.99324 0.80281 3.24095 0.905414C3.48865 1.00802 3.71373 1.1584 3.90331 1.34799C4.0929 1.53758 4.24329 1.76265 4.34589 2.01036C4.44849 2.25806 4.5013 2.52355 4.5013 2.79167V3.66667H6.83464V2.79167C6.83464 2.38786 6.95438 1.99313 7.17872 1.65738C7.40306 1.32163 7.72193 1.05994 8.09499 0.905414C8.46806 0.750885 8.87857 0.710453 9.27461 0.789231C9.67066 0.868009 10.0344 1.06246 10.32 1.34799C10.6055 1.63352 10.8 1.99731 10.8787 2.39336C10.9575 2.7894 10.9171 3.19991 10.7626 3.57298C10.608 3.94605 10.3463 4.26491 10.0106 4.48925C9.67484 4.71359 9.28011 4.83333 8.8763 4.83333H8.0013V7.16667H8.8763C9.28011 7.16667 9.67484 7.28641 10.0106 7.51075C10.3463 7.73509 10.608 8.05396 10.7626 8.42702C10.9171 8.80009 10.9575 9.2106 10.8787 9.60664C10.8 10.0027 10.6055 10.3665 10.32 10.652C10.0344 10.9375 9.67066 11.132 9.27461 11.2108C8.87857 11.2895 8.46806 11.2491 8.09499 11.0946C7.72193 10.9401 7.40306 10.6784 7.17872 10.3426C6.95438 10.0069 6.83464 9.61214 6.83464 9.20833V8.33333H4.5013V9.20833C4.5013 9.61214 4.38156 10.0069 4.15722 10.3426C3.93288 10.6784 3.61401 10.9401 3.24095 11.0946C2.86788 11.2491 2.45737 11.2895 2.06133 11.2108C1.66528 11.132 1.30149 10.9375 1.01596 10.652C0.730428 10.3665 0.535978 10.0027 0.4572 9.60664C0.378422 9.2106 0.418853 8.80009 0.573382 8.42702C0.727911 8.05396 0.989597 7.73509 1.32535 7.51075C1.6611 7.28641 2.05583 7.16667 2.45964 7.16667H3.33464V4.83333H2.45964C1.91815 4.83333 1.39885 4.61823 1.01596 4.23534C0.633073 3.85246 0.41797 3.33315 0.41797 2.79167ZM3.33464 3.66667V2.79167C3.33464 2.61861 3.28332 2.44944 3.18717 2.30554C3.09103 2.16165 2.95437 2.0495 2.79448 1.98327C2.6346 1.91705 2.45867 1.89972 2.28893 1.93348C2.1192 1.96724 1.96329 2.05058 1.84092 2.17295C1.71855 2.29532 1.63521 2.45123 1.60145 2.62096C1.56769 2.7907 1.58502 2.96663 1.65124 3.12652C1.71747 3.2864 1.82962 3.42306 1.97351 3.5192C2.11741 3.61535 2.28658 3.66667 2.45964 3.66667H3.33464ZM4.5013 4.83333V7.16667H6.83464V4.83333H4.5013ZM3.33464 8.33333H2.45964C2.28658 8.33333 2.11741 8.38465 1.97351 8.4808C1.82962 8.57694 1.71747 8.7136 1.65124 8.87349C1.58502 9.03337 1.56769 9.2093 1.60145 9.37904C1.63521 9.54877 1.71855 9.70468 1.84092 9.82705C1.96329 9.94942 2.1192 10.0328 2.28893 10.0665C2.45867 10.1003 2.6346 10.083 2.79448 10.0167C2.95437 9.9505 3.09103 9.83835 3.18717 9.69446C3.28332 9.55056 3.33464 9.38139 3.33464 9.20833V8.33333ZM8.0013 8.33333V9.20833C8.0013 9.38139 8.05262 9.55056 8.14877 9.69446C8.24491 9.83835 8.38157 9.9505 8.54146 10.0167C8.70134 10.083 8.87727 10.1003 9.04701 10.0665C9.21674 10.0328 9.37265 9.94942 9.49502 9.82705C9.61739 9.70468 9.70073 9.54877 9.73449 9.37904C9.76825 9.2093 9.75092 9.03337 9.6847 8.87349C9.61847 8.7136 9.50632 8.57694 9.36243 8.4808C9.21853 8.38465 9.04936 8.33333 8.8763 8.33333H8.0013ZM8.0013 3.66667H8.8763C9.04936 3.66667 9.21853 3.61535 9.36243 3.5192C9.50632 3.42306 9.61847 3.2864 9.6847 3.12652C9.75092 2.96663 9.76825 2.7907 9.73449 2.62096C9.70073 2.45123 9.61739 2.29532 9.49502 2.17295C9.37265 2.05058 9.21674 1.96724 9.04701 1.93348C8.87727 1.89972 8.70134 1.91705 8.54146 1.98327C8.38157 2.0495 8.24491 2.16165 8.14877 2.30554C8.05262 2.44944 8.0013 2.61861 8.0013 2.79167V3.66667Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3.5 KiB |
3
src/assets/dompurify-3.0.7.min.js
vendored
Normal file
4
src/assets/enter.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.41797 8.16406C2.41797 8.00935 2.47943 7.86098 2.58882 7.75158C2.69822 7.64219 2.84659 7.58073 3.0013 7.58073H10.0013C10.4654 7.58073 10.9106 7.39636 11.2387 7.06817C11.5669 6.73998 11.7513 6.29486 11.7513 5.83073V3.4974C11.7513 3.34269 11.8128 3.19431 11.9222 3.08492C12.0316 2.97552 12.1799 2.91406 12.3346 2.91406C12.4893 2.91406 12.6377 2.97552 12.7471 3.08492C12.8565 3.19431 12.918 3.34269 12.918 3.4974V5.83073C12.918 6.60428 12.6107 7.34614 12.0637 7.89312C11.5167 8.44011 10.7748 8.7474 10.0013 8.7474H3.0013C2.84659 8.7474 2.69822 8.68594 2.58882 8.57654C2.47943 8.46715 2.41797 8.31877 2.41797 8.16406Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.58876 8.57973C2.4794 8.47034 2.41797 8.32199 2.41797 8.16731C2.41797 8.01263 2.4794 7.86429 2.58876 7.75489L4.92209 5.42156C5.03211 5.3153 5.17946 5.25651 5.33241 5.25783C5.48536 5.25916 5.63167 5.32051 5.73982 5.42867C5.84798 5.53682 5.90932 5.68313 5.91065 5.83608C5.91198 5.98903 5.85319 6.13638 5.74693 6.24639L3.82601 8.16731L5.74693 10.0882C5.80264 10.142 5.84708 10.2064 5.87765 10.2776C5.90823 10.3487 5.92432 10.4253 5.92499 10.5027C5.92566 10.5802 5.9109 10.657 5.88157 10.7287C5.85224 10.8004 5.80893 10.8655 5.75416 10.9203C5.69939 10.9751 5.63426 11.0184 5.56257 11.0477C5.49088 11.077 5.41406 11.0918 5.33661 11.0911C5.25916 11.0905 5.18261 11.0744 5.11144 11.0438C5.04027 11.0132 4.9759 10.9688 4.92209 10.9131L2.58876 8.57973Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.6 KiB |
1213
src/assets/highlight-11.9.0.min.js
vendored
Normal file
10
src/assets/highlight-github-dark.min.css
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|